diff --git a/acapy_agent/admin/auth_context.py b/acapy_agent/admin/auth_context.py new file mode 100644 index 0000000000..1ef81d522e --- /dev/null +++ b/acapy_agent/admin/auth_context.py @@ -0,0 +1,75 @@ +"""Helpers for reading request-scoped auth context safely. + +These helpers support both: + +1) Preferred request metadata (``AdminRequestContext.metadata``), and +2) Request-scoped settings fallback for compatibility. +""" + +from collections.abc import Mapping +from typing import Any, Optional + +AUTH_SCOPES_SETTING = "auth.scopes" +AUTH_SUBJECT_SETTING = "auth.subject" +AUTH_WALLET_ID_SETTING = "auth.wallet_id" + + +def _get_context_settings(context: Any) -> Mapping[str, Any]: + settings = getattr(context, "settings", None) + if isinstance(settings, Mapping): + return settings + return {} + + +def get_auth_metadata(context: Any) -> dict: + """Return auth metadata when available, else an empty dict.""" + metadata = getattr(context, "metadata", None) + if isinstance(metadata, dict): + return metadata + return {} + + +def has_auth_scopes(context: Any) -> bool: + """Whether auth scopes are present on the request context.""" + metadata = get_auth_metadata(context) + if "scopes" in metadata: + return True + settings = _get_context_settings(context) + return AUTH_SCOPES_SETTING in settings + + +def get_auth_scopes(context: Any) -> set[str]: + """Return normalized auth scopes from metadata or request settings.""" + metadata = get_auth_metadata(context) + raw_scopes = metadata.get("scopes") + if raw_scopes is None: + raw_scopes = _get_context_settings(context).get(AUTH_SCOPES_SETTING, ()) + + if isinstance(raw_scopes, str): + return set(raw_scopes.split()) + if isinstance(raw_scopes, (set, list, tuple, frozenset)): + return {scope for scope in raw_scopes if isinstance(scope, str)} + return set() + + +def get_auth_subject(context: Any) -> Optional[str]: + """Return token subject claim from metadata or request settings.""" + metadata = get_auth_metadata(context) + subject = metadata.get("sub") + if subject is None: + subject = _get_context_settings(context).get(AUTH_SUBJECT_SETTING) + return subject if isinstance(subject, str) else None + + +def get_auth_wallet_id(context: Any) -> Optional[str]: + """Return request wallet id from metadata or request settings.""" + metadata = get_auth_metadata(context) + wallet_id = metadata.get("wallet_id") + if wallet_id is None: + wallet_id = _get_context_settings(context).get(AUTH_WALLET_ID_SETTING) + return wallet_id if isinstance(wallet_id, str) else None + + +def has_auth_wallet_id(context: Any) -> bool: + """Whether request context carries a wallet_id claim.""" + return bool(get_auth_wallet_id(context)) diff --git a/acapy_agent/admin/decorators/auth.py b/acapy_agent/admin/decorators/auth.py index 500850cbcf..16456c021b 100644 --- a/acapy_agent/admin/decorators/auth.py +++ b/acapy_agent/admin/decorators/auth.py @@ -7,6 +7,7 @@ from aiohttp import web from ...utils import general as general_utils +from ..auth_context import get_auth_scopes, has_auth_scopes from ..request_context import AdminRequestContext @@ -22,23 +23,33 @@ def admin_authentication(handler): async def admin_auth(request): context: AdminRequestContext = request["context"] profile = context.profile + + if request.method == "OPTIONS": + return await handler(request) + + # OAuth path: token was validated by setup_context middleware. + # Require acapy:admin scope for admin-only routes. + if has_auth_scopes(context): + if "acapy:admin" in get_auth_scopes(context): + return await handler(request) + raise web.HTTPForbidden( + reason="acapy:admin scope required", + text="acapy:admin scope required", + ) + header_admin_api_key = request.headers.get("x-api-key") valid_key = general_utils.const_compare( profile.settings.get("admin.admin_api_key"), header_admin_api_key ) insecure_mode = bool(profile.settings.get("admin.admin_insecure_mode")) - # We have to allow OPTIONS method access to paths without a key since - # browsers performing CORS requests will never include the original - # x-api-key header from the method that triggered the preflight - # OPTIONS check. - if insecure_mode or valid_key or (request.method == "OPTIONS"): + if insecure_mode or valid_key: return await handler(request) - else: - raise web.HTTPUnauthorized( - reason="API Key invalid or missing", - text="API Key invalid or missing", - ) + + raise web.HTTPUnauthorized( + reason="API Key invalid or missing", + text="API Key invalid or missing", + ) return admin_auth @@ -58,6 +69,15 @@ def tenant_authentication(handler): async def tenant_auth(request): context: AdminRequestContext = request["context"] profile = context.profile + + if request.method == "OPTIONS": + return await handler(request) + + # OAuth path: token was validated by setup_context middleware. + if has_auth_scopes(context): + _enforce_tenant_scope(get_auth_scopes(context), request.method) + return await handler(request) + authorization_header = request.headers.get("Authorization") header_admin_api_key = request.headers.get("x-api-key") valid_key = general_utils.const_compare( @@ -73,25 +93,82 @@ async def tenant_auth(request): request.path, ) - # CORS fix: allow OPTIONS method access to paths without a token if ( (multitenant_enabled and authorization_header) or (not multitenant_enabled and valid_key) or (multitenant_enabled and valid_key and base_wallet_allowed_route) or (insecure_mode and not multitenant_enabled) - or request.method == "OPTIONS" ): return await handler(request) - else: - auth_mode = "Authorization token" if multitenant_enabled else "API key" - raise web.HTTPUnauthorized( - reason=f"{auth_mode} missing or invalid", - text=f"{auth_mode} missing or invalid", - ) + + auth_mode = "Authorization token" if multitenant_enabled else "API key" + raise web.HTTPUnauthorized( + reason=f"{auth_mode} missing or invalid", + text=f"{auth_mode} missing or invalid", + ) return tenant_auth +def require_scope(*required_scopes: str): + """Require at least one of the given OAuth2 scopes on the request token. + + No-op when OAuth mode is not enabled (admin.oauth_enabled is not True), so + routes decorated with require_scope continue to work with API key / insecure + mode without any changes. + + Must be stacked inside tenant_authentication or admin_authentication so that + authentication is checked before scope enforcement. + + Example:: + + @tenant_authentication + @require_scope("acapy:tenant", "acapy:admin") + async def my_handler(request): ... + """ + + def decorator(handler): + @functools.wraps(handler) + async def scope_check(request): + if request.method == "OPTIONS": + return await handler(request) + context: AdminRequestContext = request["context"] + if not context.profile.settings.get("admin.oauth_enabled"): + return await handler(request) + token_scopes = get_auth_scopes(context) + if not token_scopes.intersection(required_scopes): + raise web.HTTPForbidden( + reason="Insufficient scope", + text="Insufficient scope", + ) + return await handler(request) + + return scope_check + + return decorator + + +def _enforce_tenant_scope(scopes: set, method: str) -> None: + """Authorize a tenant request by its OAuth scopes, else raise HTTPForbidden. + + ``acapy:tenant`` or ``acapy:admin`` grant tenant-level access; + ``acapy:tenant:read`` grants read-only access (safe HTTP methods only). + """ + if scopes & {"acapy:tenant", "acapy:admin"}: + return + if "acapy:tenant:read" in scopes: + if method in ("GET", "HEAD"): + return + raise web.HTTPForbidden( + reason="acapy:tenant:read scope does not permit write operations", + text="acapy:tenant:read scope does not permit write operations", + ) + raise web.HTTPForbidden( + reason="acapy:tenant scope required", + text="acapy:tenant scope required", + ) + + def _base_wallet_route_access(additional_routes: List[str], request_path: str) -> bool: """Check if request path matches additional routes.""" additional_routes_pattern = ( diff --git a/acapy_agent/admin/oauth_validator.py b/acapy_agent/admin/oauth_validator.py new file mode 100644 index 0000000000..f13b4d504b --- /dev/null +++ b/acapy_agent/admin/oauth_validator.py @@ -0,0 +1,171 @@ +"""OAuth2 token validator for ACA-Py acting as a Resource Server.""" + +import asyncio +import logging +from typing import Optional + +import aiohttp +import jwt +from aiohttp import web + +LOGGER = logging.getLogger(__name__) + +DEFAULT_HTTP_TIMEOUT = 10 + +_SUPPORTED_ALGORITHMS = [ + "RS256", + "RS384", + "RS512", + "ES256", + "ES384", + "ES512", + "PS256", + "PS384", + "PS512", +] + + +class OAuthTokenValidator: + """Validate OAuth2 bearer tokens from an external Authorization Server. + + Supports JWT access tokens validated locally via JWKS, with opaque token + fallback via RFC 7662 token introspection. + """ + + def __init__(self, settings): + """Initialize the validator from application settings.""" + self.jwks_uri: Optional[str] = settings.get("oauth.jwks_uri") + self.issuer: Optional[str] = settings.get("oauth.issuer") + self.audience: Optional[str] = settings.get("oauth.audience") + self.introspection_endpoint: Optional[str] = settings.get( + "oauth.introspection_endpoint" + ) + self.introspection_client_id: Optional[str] = settings.get( + "oauth.introspection_client_id" + ) + self.introspection_client_secret: Optional[str] = settings.get( + "oauth.introspection_client_secret" + ) + self.http_timeout: float = ( + settings.get("oauth.http_timeout") or DEFAULT_HTTP_TIMEOUT + ) + + # PyJWKClient handles JWKS fetching and caching internally. + self._jwks_client = ( + jwt.PyJWKClient(self.jwks_uri, cache_keys=True, timeout=self.http_timeout) + if self.jwks_uri + else None + ) + + # Shared HTTP session for introspection calls, created lazily on first + # use and closed via close(). + self._session: Optional[aiohttp.ClientSession] = None + + def _get_session(self) -> aiohttp.ClientSession: + """Return the shared introspection HTTP session, creating it if needed.""" + if self._session is None or self._session.closed: + self._session = aiohttp.ClientSession( + timeout=aiohttp.ClientTimeout(total=self.http_timeout) + ) + return self._session + + async def close(self): + """Release the HTTP session used for introspection calls.""" + if self._session and not self._session.closed: + await self._session.close() + self._session = None + + async def validate(self, token: str) -> dict: + """Validate an access token and return its claims. + + Tries JWT validation via JWKS first; falls back to introspection if the + token is opaque or if JWKS is not configured. + + Raises: + web.HTTPUnauthorized: If the token is invalid or inactive. + + Returns: + dict: Validated token claims. + + """ + if self._jwks_client: + try: + return await self._validate_jwt(token) + except jwt.exceptions.PyJWKClientError as exc: + # JWKS infrastructure error — only fall through if introspection is + # configured as a fallback, otherwise the token cannot be validated. + if not self.introspection_endpoint: + raise web.HTTPUnauthorized(reason="Token validation failed") from exc + LOGGER.debug("JWKS lookup failed, falling back to introspection: %s", exc) + except jwt.DecodeError: + # Token is not a JWT — only meaningful if introspection is available. + if not self.introspection_endpoint: + raise web.HTTPUnauthorized(reason="Invalid token") + LOGGER.debug("JWT decode failed, falling back to introspection") + except jwt.InvalidTokenError as exc: + raise web.HTTPUnauthorized(reason=str(exc)) from exc + + if self.introspection_endpoint: + return await self._introspect(token) + + raise web.HTTPUnauthorized( + reason="No token validation method available — configure oauth.jwks_uri or " + "oauth.introspection_endpoint" + ) + + async def _validate_jwt(self, token: str) -> dict: + """Validate a JWT access token using the configured JWKS endpoint.""" + # PyJWKClient fetches the JWKS synchronously (urllib) on a cache miss; + # run it in a thread so a slow AS doesn't block the event loop. + signing_key = await asyncio.get_running_loop().run_in_executor( + None, self._jwks_client.get_signing_key_from_jwt, token + ) + + decode_kwargs = { + "algorithms": _SUPPORTED_ALGORITHMS, + # Without verify_aud=False, PyJWT rejects any token carrying an + # aud claim when no expected audience is configured. + "options": { + "require": ["exp", "iss", "sub"], + "verify_aud": self.audience is not None, + }, + } + if self.issuer: + decode_kwargs["issuer"] = self.issuer + if self.audience: + decode_kwargs["audience"] = self.audience + + return jwt.decode(token, signing_key.key, **decode_kwargs) + + async def _introspect(self, token: str) -> dict: + """Validate an opaque token via RFC 7662 introspection.""" + auth = None + if self.introspection_client_id: + auth = aiohttp.BasicAuth( + self.introspection_client_id, + self.introspection_client_secret or "", + ) + + try: + async with self._get_session().post( + self.introspection_endpoint, + data={"token": token, "token_type_hint": "access_token"}, + auth=auth, + ) as resp: + if resp.status != 200: + raise web.HTTPUnauthorized( + reason=f"Token introspection returned HTTP {resp.status}" + ) + body = await resp.json() + except (aiohttp.ClientError, asyncio.TimeoutError) as exc: + # The Authorization Server being unreachable is a server-side + # failure, not a statement about the token's validity. + LOGGER.warning("Token introspection request failed: %s", exc) + raise web.HTTPServiceUnavailable( + reason="Token introspection unavailable" + ) from exc + + if not body.get("active"): + raise web.HTTPUnauthorized(reason="Token is not active") + + return body diff --git a/acapy_agent/admin/routes.py b/acapy_agent/admin/routes.py index 928098b973..dbd8d8a0e0 100644 --- a/acapy_agent/admin/routes.py +++ b/acapy_agent/admin/routes.py @@ -144,6 +144,7 @@ async def config_handler(request: web.BaseRequest): "wallet.rekey", "wallet.seed", "wallet.storage_creds", + "oauth.introspection_client_secret", ] } for index in range(len(config.get("admin.webhook_urls", []))): diff --git a/acapy_agent/admin/server.py b/acapy_agent/admin/server.py index 6a9c5efeb9..82040aa993 100644 --- a/acapy_agent/admin/server.py +++ b/acapy_agent/admin/server.py @@ -37,8 +37,16 @@ from ..version import __version__ from ..wallet import singletons from ..wallet.anoncreds_upgrade import check_upgrade_completion_loop +from ..wallet.models.wallet_record import WalletRecord +from .auth_context import ( + AUTH_SCOPES_SETTING, + AUTH_SUBJECT_SETTING, + AUTH_WALLET_ID_SETTING, + has_auth_wallet_id, +) from .base_server import BaseAdminServer from .error import AdminSetupError +from .oauth_validator import OAuthTokenValidator from .request_context import AdminRequestContext from .routes import ( config_handler, @@ -217,7 +225,7 @@ async def upgrade_middleware(request: web.BaseRequest, handler: Coroutine): # If we get here, than another instance started an upgrade # We need to check for completion (or fail) in another process in_progress_upgrades.set_wallet(context.profile.name) - is_subwallet = context.metadata and "wallet_id" in context.metadata + is_subwallet = has_auth_wallet_id(context) # Create background task and store reference to prevent garbage collection task = asyncio.create_task( @@ -302,14 +310,24 @@ def __init__( self.site = None self.multitenant_manager = context.inject_or(BaseMultitenantManager) + oauth_mode = bool( + context.settings.get("admin.oauth_enabled") + or context.settings.get("oauth.jwks_uri") + or context.settings.get("oauth.introspection_endpoint") + ) + self.oauth_validator = ( + OAuthTokenValidator(context.settings) if oauth_mode else None + ) + async def make_application(self) -> web.Application: """Get the aiohttp application instance.""" middlewares = [ready_middleware, debug_middleware] - # admin-token and admin-token are mutually exclusive and required. - # This should be enforced during parameter parsing but to be sure, - # we check here. - assert self.admin_insecure_mode ^ bool(self.admin_api_key) + # In OAuth mode neither api-key nor insecure-mode is required; the AS + # is the sole authentication authority. Otherwise exactly one of the + # two legacy modes must be set (enforced in argparse too). + if not self.oauth_validator: + assert self.admin_insecure_mode ^ bool(self.admin_api_key) collector = self.context.inject_or(Collector) @@ -318,8 +336,54 @@ async def setup_context(request: web.Request, handler): authorization_header = request.headers.get("Authorization") profile = self.root_profile meta_data = {} - # Multitenancy context setup - if self.multitenant_manager and authorization_header: + request_settings = {} + + if self.oauth_validator and authorization_header: + # OAuth2 Resource Server path — token issued by external AS. + bearer, _, token = authorization_header.partition(" ") + if bearer != "Bearer": + raise web.HTTPUnauthorized( + reason="Invalid Authorization header structure" + ) + claims = await self.oauth_validator.validate(token) + scopes = set(claims.get("scope", "").split()) + meta_data = {"scopes": scopes, "sub": claims.get("sub")} + request_settings[AUTH_SCOPES_SETTING] = tuple(sorted(scopes)) + if claims.get("sub"): + request_settings[AUTH_SUBJECT_SETTING] = claims["sub"] + + if self.multitenant_manager: + wallet_id = claims.get("wallet_id") + if wallet_id: + try: + async with self.root_profile.session() as session: + wallet = await WalletRecord.retrieve_by_id( + session, wallet_id + ) + profile = await self.multitenant_manager.get_wallet_profile( + self.context, wallet + ) + context_wallet_id.set(wallet_id) + meta_data["wallet_id"] = wallet_id + request_settings[AUTH_WALLET_ID_SETTING] = wallet_id + except StorageNotFoundError: + raise web.HTTPUnauthorized( + reason=( + f"Wallet not found for wallet_id claim: {wallet_id}" + ) + ) + elif "acapy:admin" not in scopes: + # Without a wallet_id claim the request would run against + # the base wallet; only admin-scoped tokens may do that. + raise web.HTTPUnauthorized( + reason=( + "Token must include a wallet_id claim (or the " + "acapy:admin scope) in multitenant mode" + ) + ) + + elif self.multitenant_manager and authorization_header: + # Legacy ACA-Py JWT path (multitenant without OAuth). try: bearer, _, token = authorization_header.partition(" ") if bearer != "Bearer": @@ -342,6 +406,7 @@ async def setup_context(request: web.Request, handler): "wallet_id": walletid, "wallet_key": walletkey, } + request_settings[AUTH_WALLET_ID_SETTING] = walletid except MultitenantManagerError as err: raise web.HTTPUnauthorized(reason=err.roll_up) except (jwt.InvalidTokenError, StorageNotFoundError): @@ -354,17 +419,17 @@ async def setup_context(request: web.Request, handler): ) profile.context.injector.bind_instance(BaseResponder, responder) - # TODO may dynamically adjust the profile used here according to - # headers or other parameters - if self.multitenant_manager and authorization_header: + if meta_data: admin_context = AdminRequestContext( profile=profile, root_profile=self.root_profile, + settings=request_settings, metadata=meta_data, ) else: admin_context = AdminRequestContext( profile=profile, + settings=request_settings, ) request["context"] = admin_context @@ -519,6 +584,9 @@ def sort_dict(raw: dict) -> dict: async def stop(self) -> None: """Stop the webserver.""" + if self.oauth_validator: + await self.oauth_validator.close() + # Stopped before admin server is created if not self.app: return @@ -535,29 +603,41 @@ async def on_startup(self, app: web.Application): security_definitions = {} security = [] - if self.admin_api_key: - security_definitions["ApiKeyHeader"] = { - "type": "apiKey", - "in": "header", - "name": "X-API-KEY", - } - security.append({"ApiKeyHeader": []}) - if self.multitenant_manager: - security_definitions["AuthorizationHeader"] = { + if self.oauth_validator: + security_definitions["OAuth2Bearer"] = { "type": "apiKey", "in": "header", "name": "Authorization", - "description": "Bearer token. Be sure to prepend token with 'Bearer '", + "description": ( + "OAuth2 Bearer token issued by the Authorization Server. " + "Prepend with 'Bearer '." + ), } - - # If multitenancy is enabled we need Authorization header - multitenant_security = {"AuthorizationHeader": []} - # If admin api key is also enabled, we need both for subwallet requests + security.append({"OAuth2Bearer": []}) + else: if self.admin_api_key: - multitenant_security["ApiKeyHeader"] = [] - security.append(multitenant_security) + security_definitions["ApiKeyHeader"] = { + "type": "apiKey", + "in": "header", + "name": "X-API-KEY", + } + security.append({"ApiKeyHeader": []}) + if self.multitenant_manager: + security_definitions["AuthorizationHeader"] = { + "type": "apiKey", + "in": "header", + "name": "Authorization", + "description": ( + "Bearer token. Be sure to prepend token with 'Bearer '" + ), + } - if self.admin_api_key or self.multitenant_manager: + multitenant_security = {"AuthorizationHeader": []} + if self.admin_api_key: + multitenant_security["ApiKeyHeader"] = [] + security.append(multitenant_security) + + if self.oauth_validator or self.admin_api_key or self.multitenant_manager: swagger = app["swagger_dict"] swagger["securityDefinitions"] = security_definitions swagger["security"] = security @@ -568,15 +648,62 @@ def notify_fatal_error(self): self.app._state["ready"] = False self.app._state["alive"] = False + def _authorize_ws_from_claims(self, queue, claims: dict) -> None: + """Set websocket authorization on the queue from validated token claims. + + Mirrors the HTTP auth decorators so the admin event stream honours the + same scope and tenant-isolation rules: + + - ``acapy:admin`` receives the full cross-wallet event stream. + - ``acapy:tenant`` / ``acapy:tenant:read`` receives only its own wallet's + events; in multitenant mode a ``wallet_id`` claim is required. + - Any other token is treated as unauthenticated (no events delivered). + """ + scopes = set(claims.get("scope", "").split()) + wallet_id = claims.get("wallet_id") + + if "acapy:admin" in scopes: + queue.authenticated = True + queue.receive_all = True + elif scopes & {"acapy:tenant", "acapy:tenant:read"}: + if self.multitenant_manager and not wallet_id: + # A tenant token with no wallet binding must not receive events. + queue.authenticated = False + else: + queue.authenticated = True + queue.wallet_id = wallet_id + queue.receive_all = not self.multitenant_manager + else: + queue.authenticated = False + async def websocket_handler(self, request): """Send notifications to admin client over websocket.""" ws = web.WebSocketResponse() await ws.prepare(request) socket_id = str(uuid4()) queue = BasicMessageQueue() + # Which wallet's events this socket may receive. receive_all grants the + # full cross-wallet stream (base-wallet / admin identities); otherwise + # only events for wallet_id are delivered. See send_webhook. + queue.wallet_id = None + queue.receive_all = True loop = asyncio.get_event_loop() - if self.admin_insecure_mode: + if self.oauth_validator: + authorization_header = request.headers.get("Authorization") + if authorization_header: + bearer, _, token = authorization_header.partition(" ") + try: + if bearer == "Bearer": + claims = await self.oauth_validator.validate(token) + self._authorize_ws_from_claims(queue, claims) + else: + queue.authenticated = False + except Exception: + queue.authenticated = False + else: + queue.authenticated = False + elif self.admin_insecure_mode: # open to send websocket messages without api key auth queue.authenticated = True else: @@ -625,10 +752,15 @@ async def websocket_handler(self, request): msg_api_key = msg_received.get("x-api-key") except Exception: LOGGER.exception("Exception in websocket receiving task:") - if self.admin_api_key and general_utils.const_compare( - self.admin_api_key, msg_api_key + if ( + not self.oauth_validator + and self.admin_api_key + and general_utils.const_compare( + self.admin_api_key, msg_api_key + ) ): # authenticated via websocket message + # (legacy api-key mode) queue.authenticated = True receive = loop.create_task(ws.receive_json()) @@ -703,5 +835,12 @@ async def send_webhook( webhook_body["wallet_id"] = wallet_id for queue in self.websocket_queues.values(): - if queue.authenticated or topic in ("ping", "settings"): + if topic in ("ping", "settings"): + await queue.enqueue(webhook_body) + elif queue.authenticated and ( + getattr(queue, "receive_all", True) + or getattr(queue, "wallet_id", None) == wallet_id + ): + # Deliver only to sockets authorized for this event's wallet; + # receive_all covers base-wallet / admin identities. await queue.enqueue(webhook_body) diff --git a/acapy_agent/admin/tests/test_admin_server.py b/acapy_agent/admin/tests/test_admin_server.py index 25ce605f41..d492604d51 100644 --- a/acapy_agent/admin/tests/test_admin_server.py +++ b/acapy_agent/admin/tests/test_admin_server.py @@ -6,7 +6,13 @@ import jwt import pytest import pytest_asyncio -from aiohttp import ClientSession, DummyCookieJar, TCPConnector, web +from aiohttp import ( + ClientSession, + DummyCookieJar, + TCPConnector, + WSServerHandshakeError, + web, +) from aiohttp.test_utils import unused_port from marshmallow import ValidationError @@ -16,12 +22,14 @@ from ...core.event_bus import Event from ...core.goal_code_registry import GoalCodeRegistry from ...core.protocol_registry import ProtocolRegistry +from ...multitenant.base import BaseMultitenantManager from ...multitenant.error import MultitenantManagerError from ...storage.base import BaseStorage from ...storage.error import StorageNotFoundError from ...storage.record import StorageRecord from ...storage.type import RECORD_TYPE_ACAPY_UPGRADING from ...tests import mock +from ...transport.queue.basic import BasicMessageQueue from ...utils.stats import Collector from ...utils.task_queue import TaskQueue from ...utils.testing import create_test_profile @@ -454,6 +462,7 @@ async def test_query_config(self): "wallet.rekey": "def456", "wallet.seed": "00000000000000000000000000000000", "wallet.storage.creds": "secret", + "oauth.introspection_client_secret": "introspect-secret", } server = await self.get_admin_server(settings) await server.start() @@ -473,6 +482,7 @@ async def test_query_config(self): "wallet.rekey", "wallet.seed", "wallet.storage_creds", + "oauth.introspection_client_secret", ] ) assert config["admin.webhook_urls"] == [ @@ -591,6 +601,324 @@ async def test_upgrade_middleware(self): singletons.IsAnonCredsSingleton().set_wallet(profile.name) await test_module.upgrade_middleware(request, handler) + async def get_oauth_admin_server( + self, claims: dict, multitenant: bool = False + ) -> AdminServer: + """Admin server in OAuth mode whose validator returns the given claims.""" + settings = {"admin.oauth_enabled": True} + if multitenant: + settings["multitenant.enabled"] = True + context = InjectionContext() + if multitenant: + self.multitenant_manager = mock.MagicMock( + BaseMultitenantManager, autospec=True + ) + context.injector.bind_instance( + BaseMultitenantManager, self.multitenant_manager + ) + server = await self.get_admin_server(settings, context) + server.oauth_validator.validate = mock.CoroutineMock(return_value=claims) + return server + + async def test_oauth_admin_scope_allows_admin_route(self): + server = await self.get_oauth_admin_server( + {"scope": "acapy:admin", "sub": "svc-admin"} + ) + await server.start() + + async with self.client_session.get( + f"http://127.0.0.1:{self.port}/status", + headers={"Authorization": "Bearer valid-token"}, + ) as response: + assert response.status == 200 + server.oauth_validator.validate.assert_awaited_with("valid-token") + + await server.stop() + + async def test_oauth_tenant_scope_forbidden_on_admin_route(self): + server = await self.get_oauth_admin_server( + {"scope": "acapy:tenant", "sub": "user-1"} + ) + await server.start() + + async with self.client_session.get( + f"http://127.0.0.1:{self.port}/status", + headers={"Authorization": "Bearer valid-token"}, + ) as response: + assert response.status == 403 + + await server.stop() + + async def test_oauth_malformed_authorization_header(self): + server = await self.get_oauth_admin_server({"scope": "acapy:admin"}) + await server.start() + + async with self.client_session.get( + f"http://127.0.0.1:{self.port}/status", + headers={"Authorization": "Basic dXNlcjpwYXNz"}, + ) as response: + assert response.status == 401 + + async with self.client_session.get( + f"http://127.0.0.1:{self.port}/status", headers={} + ) as response: + assert response.status == 401 + + await server.stop() + + async def test_oauth_invalid_token_rejected(self): + server = await self.get_oauth_admin_server({}) + server.oauth_validator.validate = mock.CoroutineMock( + side_effect=web.HTTPUnauthorized(reason="Token validation failed") + ) + await server.start() + + async with self.client_session.get( + f"http://127.0.0.1:{self.port}/status", + headers={"Authorization": "Bearer bad-token"}, + ) as response: + assert response.status == 401 + + await server.stop() + + async def test_oauth_multitenant_wallet_id_claim_selects_profile(self): + server = await self.get_oauth_admin_server( + {"scope": "acapy:admin", "sub": "user-1", "wallet_id": "wallet-1"}, + multitenant=True, + ) + self.multitenant_manager.get_wallet_profile = mock.CoroutineMock( + return_value=self.profile + ) + await server.start() + + with mock.patch.object( + test_module.WalletRecord, + "retrieve_by_id", + mock.CoroutineMock(return_value=mock.MagicMock()), + ) as mock_retrieve: + async with self.client_session.get( + f"http://127.0.0.1:{self.port}/status", + headers={"Authorization": "Bearer valid-token"}, + ) as response: + assert response.status == 200 + assert mock_retrieve.call_args.args[1] == "wallet-1" + self.multitenant_manager.get_wallet_profile.assert_awaited_once() + + await server.stop() + + async def test_oauth_multitenant_unknown_wallet_id_rejected(self): + server = await self.get_oauth_admin_server( + {"scope": "acapy:admin", "wallet_id": "no-such-wallet"}, + multitenant=True, + ) + await server.start() + + with mock.patch.object( + test_module.WalletRecord, + "retrieve_by_id", + mock.CoroutineMock(side_effect=StorageNotFoundError()), + ): + async with self.client_session.get( + f"http://127.0.0.1:{self.port}/status", + headers={"Authorization": "Bearer valid-token"}, + ) as response: + assert response.status == 401 + + await server.stop() + + async def test_oauth_multitenant_missing_wallet_id_rejected_for_non_admin(self): + server = await self.get_oauth_admin_server( + {"scope": "acapy:tenant", "sub": "user-1"}, multitenant=True + ) + await server.start() + + async with self.client_session.get( + f"http://127.0.0.1:{self.port}/status", + headers={"Authorization": "Bearer valid-token"}, + ) as response: + assert response.status == 401 + + await server.stop() + + async def test_oauth_multitenant_missing_wallet_id_allowed_for_admin(self): + server = await self.get_oauth_admin_server( + {"scope": "acapy:admin", "sub": "svc-admin"}, multitenant=True + ) + await server.start() + + async with self.client_session.get( + f"http://127.0.0.1:{self.port}/status", + headers={"Authorization": "Bearer valid-token"}, + ) as response: + assert response.status == 200 + + await server.stop() + + async def test_legacy_multitenant_bearer_token(self): + """Multitenant without OAuth still uses ACA-Py issued JWTs.""" + settings = { + "admin.admin_insecure_mode": True, + "multitenant.enabled": True, + } + context = InjectionContext() + multitenant_manager = mock.MagicMock(BaseMultitenantManager, autospec=True) + context.injector.bind_instance(BaseMultitenantManager, multitenant_manager) + server = await self.get_admin_server(settings, context) + multitenant_manager.get_profile_for_token = mock.CoroutineMock( + return_value=self.profile + ) + multitenant_manager.get_wallet_details_from_token = mock.MagicMock( + return_value=("wallet-1", "wallet-key") + ) + await server.start() + + async with self.client_session.get( + f"http://127.0.0.1:{self.port}/status", + headers={"Authorization": "Bearer acapy-jwt"}, + ) as response: + assert response.status == 200 + multitenant_manager.get_profile_for_token.assert_awaited_once() + + multitenant_manager.get_profile_for_token = mock.CoroutineMock( + side_effect=MultitenantManagerError("bad token") + ) + async with self.client_session.get( + f"http://127.0.0.1:{self.port}/status", + headers={"Authorization": "Bearer acapy-jwt"}, + ) as response: + assert response.status == 401 + + await server.stop() + + async def test_oauth_swagger_security_definitions(self): + server = await self.get_oauth_admin_server({"scope": "acapy:admin"}) + await server.start() + + swagger = server.app["swagger_dict"] + assert "OAuth2Bearer" in swagger["securityDefinitions"] + assert {"OAuth2Bearer": []} in swagger["security"] + + await server.stop() + + async def test_oauth_websocket_authentication(self): + server = await self.get_oauth_admin_server({"scope": "acapy:admin"}) + await server.start() + + async with self.client_session.ws_connect( + f"http://127.0.0.1:{self.port}/ws", + headers={"Authorization": "Bearer valid-token"}, + ) as ws: + result = await ws.receive_json() + assert result["topic"] == "settings" + assert result["payload"]["authenticated"] is True + + # An invalid token is rejected by the setup_context middleware before + # the websocket handler runs, failing the handshake outright. + server.oauth_validator.validate = mock.CoroutineMock( + side_effect=web.HTTPUnauthorized() + ) + with self.assertRaises(WSServerHandshakeError): + await self.client_session.ws_connect( + f"http://127.0.0.1:{self.port}/ws", + headers={"Authorization": "Bearer bad-token"}, + ) + + async with self.client_session.ws_connect( + f"http://127.0.0.1:{self.port}/ws" + ) as ws: + result = await ws.receive_json() + assert result["payload"]["authenticated"] is False + + await server.stop() + + async def test_authorize_ws_admin_scope_receives_all(self): + server = await self.get_oauth_admin_server({}, multitenant=True) + queue = BasicMessageQueue() + queue.wallet_id = None + queue.receive_all = True + server._authorize_ws_from_claims( + queue, {"scope": "acapy:admin", "wallet_id": "wallet-1"} + ) + assert queue.authenticated is True + assert queue.receive_all is True + + async def test_authorize_ws_tenant_scope_scoped_to_wallet(self): + server = await self.get_oauth_admin_server({}, multitenant=True) + queue = BasicMessageQueue() + queue.wallet_id = None + queue.receive_all = True + server._authorize_ws_from_claims( + queue, {"scope": "acapy:tenant", "wallet_id": "wallet-A"} + ) + assert queue.authenticated is True + assert queue.receive_all is False + assert queue.wallet_id == "wallet-A" + + async def test_authorize_ws_tenant_scope_without_wallet_rejected(self): + server = await self.get_oauth_admin_server({}, multitenant=True) + queue = BasicMessageQueue() + queue.wallet_id = None + queue.receive_all = True + server._authorize_ws_from_claims(queue, {"scope": "acapy:tenant"}) + assert queue.authenticated is False + + async def test_authorize_ws_tenant_scope_single_tenant_receives_all(self): + server = await self.get_oauth_admin_server({}) # no multitenant manager + queue = BasicMessageQueue() + queue.wallet_id = None + queue.receive_all = True + server._authorize_ws_from_claims(queue, {"scope": "acapy:tenant:read"}) + assert queue.authenticated is True + assert queue.receive_all is True + + async def test_authorize_ws_unrecognized_scope_not_authenticated(self): + server = await self.get_oauth_admin_server({}, multitenant=True) + queue = BasicMessageQueue() + queue.wallet_id = None + queue.receive_all = True + server._authorize_ws_from_claims( + queue, {"scope": "openid profile", "wallet_id": "wallet-A"} + ) + assert queue.authenticated is False + + async def test_send_webhook_filters_by_wallet(self): + server = await self.get_admin_server({"admin.admin_insecure_mode": True}) + + def _queue(authenticated, receive_all, wallet_id): + q = BasicMessageQueue() + q.authenticated = authenticated + q.receive_all = receive_all + q.wallet_id = wallet_id + return q + + q_admin = _queue(True, True, None) + q_tenant_a = _queue(True, False, "wallet-A") + q_tenant_b = _queue(True, False, "wallet-B") + q_unauth = _queue(False, False, "wallet-A") + server.websocket_queues = { + "admin": q_admin, + "A": q_tenant_a, + "B": q_tenant_b, + "unauth": q_unauth, + } + + profile = mock.MagicMock() + profile.settings = {"wallet.id": "wallet-A", "admin.webhook_urls": []} + + await server.send_webhook(profile, "connections", {"foo": "bar"}) + + # admin (receive_all) and the matching tenant get the event; + # the other tenant and the unauthenticated socket do not. + assert q_admin.queue.qsize() == 1 + assert q_tenant_a.queue.qsize() == 1 + assert q_tenant_b.queue.qsize() == 0 + assert q_unauth.queue.qsize() == 0 + + # settings/ping are delivered to every socket regardless of auth + await server.send_webhook(profile, "settings", {}) + assert q_unauth.queue.qsize() == 1 + assert q_tenant_b.queue.qsize() == 1 + @pytest_asyncio.fixture async def server(): diff --git a/acapy_agent/admin/tests/test_auth.py b/acapy_agent/admin/tests/test_auth.py index 765d7ef213..3f306c5312 100644 --- a/acapy_agent/admin/tests/test_auth.py +++ b/acapy_agent/admin/tests/test_auth.py @@ -4,7 +4,7 @@ from ...tests import mock from ...utils.testing import create_test_profile -from ..decorators.auth import admin_authentication, tenant_authentication +from ..decorators.auth import admin_authentication, require_scope, tenant_authentication from ..request_context import AdminRequestContext @@ -59,6 +59,34 @@ async def test_valid_api_key(self): await decor_func(self.request) self.decorated_handler.assert_called_once_with(self.request) + def _make_oauth_request(self, scopes, method="POST"): + context = AdminRequestContext( + profile=self.profile, metadata={"scopes": set(scopes)} + ) + return mock.MagicMock( + __getitem__=lambda _, k: {"context": context}[k], + headers={}, + method=method, + ) + + async def test_oauth_admin_scope_allowed(self): + request = self._make_oauth_request(["acapy:admin"]) + decor_func = admin_authentication(self.decorated_handler) + await decor_func(request) + self.decorated_handler.assert_called_once_with(request) + + async def test_oauth_tenant_scope_forbidden(self): + request = self._make_oauth_request(["acapy:tenant"]) + decor_func = admin_authentication(self.decorated_handler) + with self.assertRaises(web.HTTPForbidden): + await decor_func(request) + + async def test_oauth_no_scopes_forbidden(self): + request = self._make_oauth_request([]) + decor_func = admin_authentication(self.decorated_handler) + with self.assertRaises(web.HTTPForbidden): + await decor_func(request) + class TestTenantAuthentication(IsolatedAsyncioTestCase): async def asyncSetUp(self) -> None: @@ -174,3 +202,103 @@ async def test_base_wallet_additional_route_denied(self): decor_func = tenant_authentication(self.decorated_handler) with self.assertRaises(web.HTTPUnauthorized): await decor_func(self.request) + + def _make_oauth_request(self, scopes, method="POST"): + context = AdminRequestContext( + profile=self.profile, metadata={"scopes": set(scopes)} + ) + return mock.MagicMock( + __getitem__=lambda _, k: {"context": context}[k], + headers={}, + method=method, + ) + + async def test_oauth_tenant_scope_allowed(self): + request = self._make_oauth_request(["acapy:tenant"]) + decor_func = tenant_authentication(self.decorated_handler) + await decor_func(request) + self.decorated_handler.assert_called_once_with(request) + + async def test_oauth_admin_scope_allowed_on_tenant_route(self): + request = self._make_oauth_request(["acapy:admin"]) + decor_func = tenant_authentication(self.decorated_handler) + await decor_func(request) + self.decorated_handler.assert_called_once_with(request) + + async def test_oauth_read_scope_allows_get(self): + request = self._make_oauth_request(["acapy:tenant:read"], method="GET") + decor_func = tenant_authentication(self.decorated_handler) + await decor_func(request) + self.decorated_handler.assert_called_once_with(request) + + async def test_oauth_read_scope_forbids_write(self): + request = self._make_oauth_request(["acapy:tenant:read"], method="POST") + decor_func = tenant_authentication(self.decorated_handler) + with self.assertRaises(web.HTTPForbidden): + await decor_func(request) + + async def test_oauth_unrelated_scope_forbidden(self): + request = self._make_oauth_request(["profile email"]) + decor_func = tenant_authentication(self.decorated_handler) + with self.assertRaises(web.HTTPForbidden): + await decor_func(request) + + +class TestRequireScope(IsolatedAsyncioTestCase): + async def asyncSetUp(self) -> None: + self.profile = await create_test_profile() + self.decorated_handler = mock.CoroutineMock() + + def _make_request(self, scopes=None, method="POST"): + metadata = {"scopes": set(scopes)} if scopes is not None else None + context = AdminRequestContext(profile=self.profile, metadata=metadata) + return mock.MagicMock( + __getitem__=lambda _, k: {"context": context}[k], + headers={}, + method=method, + ) + + async def test_options_always_passes(self): + request = self._make_request(method="OPTIONS") + decor = require_scope("acapy:tenant")(self.decorated_handler) + await decor(request) + self.decorated_handler.assert_called_once_with(request) + + async def test_non_oauth_mode_passes_without_scopes(self): + """require_scope is a no-op when admin.oauth_enabled is not set.""" + request = self._make_request() # no metadata / no scopes + decor = require_scope("acapy:tenant")(self.decorated_handler) + await decor(request) + self.decorated_handler.assert_called_once_with(request) + + async def test_oauth_mode_passes_with_required_scope(self): + self.profile.settings["admin.oauth_enabled"] = True + request = self._make_request(scopes=["acapy:tenant"]) + decor = require_scope("acapy:tenant", "acapy:admin")(self.decorated_handler) + await decor(request) + self.decorated_handler.assert_called_once_with(request) + + async def test_oauth_mode_admin_scope_satisfies_any_requirement(self): + self.profile.settings["admin.oauth_enabled"] = True + request = self._make_request(scopes=["acapy:admin"]) + decor = require_scope("acapy:wallet:create", "acapy:admin")( + self.decorated_handler + ) + await decor(request) + self.decorated_handler.assert_called_once_with(request) + + async def test_oauth_mode_raises_403_on_insufficient_scope(self): + self.profile.settings["admin.oauth_enabled"] = True + request = self._make_request(scopes=["acapy:tenant:read"]) + decor = require_scope("acapy:wallet:create", "acapy:admin")( + self.decorated_handler + ) + with self.assertRaises(web.HTTPForbidden): + await decor(request) + + async def test_oauth_mode_raises_403_when_no_scopes_in_token(self): + self.profile.settings["admin.oauth_enabled"] = True + request = self._make_request(scopes=[]) + decor = require_scope("acapy:tenant")(self.decorated_handler) + with self.assertRaises(web.HTTPForbidden): + await decor(request) diff --git a/acapy_agent/admin/tests/test_auth_context.py b/acapy_agent/admin/tests/test_auth_context.py new file mode 100644 index 0000000000..53114ce05d --- /dev/null +++ b/acapy_agent/admin/tests/test_auth_context.py @@ -0,0 +1,70 @@ +from unittest import TestCase + +from ..auth_context import ( + AUTH_SCOPES_SETTING, + AUTH_SUBJECT_SETTING, + AUTH_WALLET_ID_SETTING, + get_auth_scopes, + get_auth_subject, + get_auth_wallet_id, + has_auth_scopes, + has_auth_wallet_id, +) + + +class _DummyContext: + def __init__(self, metadata=None, settings=None): + self.metadata = metadata + self.settings = settings if settings is not None else {} + + +class TestAuthContextHelpers(TestCase): + def test_scopes_from_metadata(self): + context = _DummyContext(metadata={"scopes": {"acapy:admin"}}) + + assert has_auth_scopes(context) + assert get_auth_scopes(context) == {"acapy:admin"} + + def test_scopes_from_settings_fallback(self): + context = _DummyContext(settings={AUTH_SCOPES_SETTING: ("acapy:tenant",)}) + + assert has_auth_scopes(context) + assert get_auth_scopes(context) == {"acapy:tenant"} + + def test_subject_from_settings_fallback(self): + context = _DummyContext(settings={AUTH_SUBJECT_SETTING: "alice"}) + + assert get_auth_subject(context) == "alice" + + def test_wallet_id_prefers_metadata_then_settings(self): + context = _DummyContext( + metadata={"wallet_id": "metadata-wallet"}, + settings={AUTH_WALLET_ID_SETTING: "settings-wallet"}, + ) + + assert has_auth_wallet_id(context) + assert get_auth_wallet_id(context) == "metadata-wallet" + + def test_wallet_id_from_settings_when_metadata_missing(self): + context = _DummyContext(settings={AUTH_WALLET_ID_SETTING: "settings-wallet"}) + + assert has_auth_wallet_id(context) + assert get_auth_wallet_id(context) == "settings-wallet" + + def test_non_mapping_settings_treated_as_empty(self): + context = _DummyContext(settings="not-a-mapping") + + assert not has_auth_scopes(context) + assert get_auth_scopes(context) == set() + assert get_auth_subject(context) is None + assert get_auth_wallet_id(context) is None + + def test_scopes_from_string_and_invalid_types(self): + context = _DummyContext(metadata={"scopes": "acapy:tenant acapy:admin"}) + assert get_auth_scopes(context) == {"acapy:tenant", "acapy:admin"} + + context = _DummyContext(metadata={"scopes": {"acapy:tenant", 42}}) + assert get_auth_scopes(context) == {"acapy:tenant"} + + context = _DummyContext(metadata={"scopes": 42}) + assert get_auth_scopes(context) == set() diff --git a/acapy_agent/admin/tests/test_oauth_validator.py b/acapy_agent/admin/tests/test_oauth_validator.py new file mode 100644 index 0000000000..8df36c622f --- /dev/null +++ b/acapy_agent/admin/tests/test_oauth_validator.py @@ -0,0 +1,293 @@ +"""Tests for the OAuth2 token validator.""" + +import asyncio +import time +from types import SimpleNamespace +from unittest import IsolatedAsyncioTestCase + +import aiohttp +import jwt +from aiohttp import web +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.asymmetric import rsa + +from ...tests import mock +from ..oauth_validator import OAuthTokenValidator + +ISSUER = "https://as.example.com/realms/test" +AUDIENCE = "acapy-admin" +JWKS_URI = "https://as.example.com/realms/test/protocol/openid-connect/certs" +INTROSPECTION_ENDPOINT = ( + "https://as.example.com/realms/test/protocol/openid-connect/token/introspect" +) + +RSA_KEY = rsa.generate_private_key(public_exponent=65537, key_size=2048) +RSA_PRIVATE_PEM = RSA_KEY.private_bytes( + serialization.Encoding.PEM, + serialization.PrivateFormat.PKCS8, + serialization.NoEncryption(), +) +RSA_PUBLIC_KEY = RSA_KEY.public_key() + +OTHER_RSA_KEY = rsa.generate_private_key(public_exponent=65537, key_size=2048) +OTHER_RSA_PRIVATE_PEM = OTHER_RSA_KEY.private_bytes( + serialization.Encoding.PEM, + serialization.PrivateFormat.PKCS8, + serialization.NoEncryption(), +) + + +def make_jwt( + *, + key=RSA_PRIVATE_PEM, + algorithm="RS256", + exp_delta=300, + issuer=ISSUER, + audience=AUDIENCE, + sub="user-123", + drop_claims=(), + **extra_claims, +) -> str: + claims = { + "exp": int(time.time()) + exp_delta, + "iss": issuer, + "aud": audience, + "sub": sub, + **extra_claims, + } + for claim in drop_claims: + claims.pop(claim, None) + return jwt.encode(claims, key, algorithm=algorithm) + + +def make_validator(**settings) -> OAuthTokenValidator: + return OAuthTokenValidator(settings) + + +def make_jwks_validator(**settings) -> OAuthTokenValidator: + """Validator with JWKS configured and key lookup returning the test key.""" + validator = make_validator( + **{ + "oauth.jwks_uri": JWKS_URI, + "oauth.issuer": ISSUER, + "oauth.audience": AUDIENCE, + **settings, + } + ) + validator._jwks_client.get_signing_key_from_jwt = mock.MagicMock( + return_value=SimpleNamespace(key=RSA_PUBLIC_KEY) + ) + return validator + + +def make_response(status=200, body=None): + response = mock.MagicMock(status=status) + response.json = mock.CoroutineMock(return_value=body or {}) + return response + + +def mock_session(response=None, post_exc=None): + """Mock ClientSession whose post() context manager yields response.""" + post_cm = mock.MagicMock() + if post_exc: + post_cm.__aenter__ = mock.CoroutineMock(side_effect=post_exc) + else: + post_cm.__aenter__ = mock.CoroutineMock(return_value=response) + post_cm.__aexit__ = mock.CoroutineMock(return_value=False) + session = mock.MagicMock() + session.post = mock.MagicMock(return_value=post_cm) + return session + + +class TestValidatorConfig(IsolatedAsyncioTestCase): + def test_jwks_client_only_created_when_configured(self): + assert make_validator()._jwks_client is None + assert make_validator(**{"oauth.jwks_uri": JWKS_URI})._jwks_client + + async def test_no_validation_method_raises_unauthorized(self): + validator = make_validator() + with self.assertRaises(web.HTTPUnauthorized): + await validator.validate("any-token") + + def test_http_timeout_default(self): + validator = make_validator(**{"oauth.jwks_uri": JWKS_URI}) + assert validator.http_timeout == 10 + assert validator._jwks_client.timeout == 10 + + async def test_http_timeout_override_applies_to_jwks_and_session(self): + validator = make_validator( + **{ + "oauth.jwks_uri": JWKS_URI, + "oauth.introspection_endpoint": INTROSPECTION_ENDPOINT, + "oauth.http_timeout": 3, + } + ) + assert validator.http_timeout == 3 + assert validator._jwks_client.timeout == 3 + session = validator._get_session() + assert session.timeout.total == 3 + await validator.close() + + +class TestJwtValidation(IsolatedAsyncioTestCase): + async def test_valid_jwt_returns_claims(self): + validator = make_jwks_validator() + token = make_jwt(scope="acapy:tenant", wallet_id="wallet-1") + claims = await validator.validate(token) + assert claims["sub"] == "user-123" + assert claims["scope"] == "acapy:tenant" + assert claims["wallet_id"] == "wallet-1" + + async def test_expired_jwt_rejected(self): + validator = make_jwks_validator() + with self.assertRaises(web.HTTPUnauthorized): + await validator.validate(make_jwt(exp_delta=-300)) + + async def test_wrong_issuer_rejected(self): + validator = make_jwks_validator() + with self.assertRaises(web.HTTPUnauthorized): + await validator.validate(make_jwt(issuer="https://evil.example.com")) + + async def test_wrong_audience_rejected(self): + validator = make_jwks_validator() + with self.assertRaises(web.HTTPUnauthorized): + await validator.validate(make_jwt(audience="other-api")) + + async def test_missing_required_claim_rejected(self): + validator = make_jwks_validator() + with self.assertRaises(web.HTTPUnauthorized): + await validator.validate(make_jwt(drop_claims=("sub",))) + + async def test_wrong_signing_key_rejected(self): + validator = make_jwks_validator() + with self.assertRaises(web.HTTPUnauthorized): + await validator.validate(make_jwt(key=OTHER_RSA_PRIVATE_PEM)) + + async def test_symmetric_algorithm_rejected(self): + """HS256 tokens must be rejected (algorithm confusion protection).""" + validator = make_jwks_validator() + with self.assertRaises(web.HTTPUnauthorized): + await validator.validate(make_jwt(key="shared-secret", algorithm="HS256")) + + async def test_issuer_and_audience_not_enforced_when_unset(self): + validator = make_jwks_validator(**{"oauth.issuer": None, "oauth.audience": None}) + claims = await validator.validate( + make_jwt(issuer="https://any.example.com", audience="any-api") + ) + assert claims["sub"] == "user-123" + + async def test_opaque_token_without_introspection_rejected(self): + validator = make_jwks_validator() + with self.assertRaises(web.HTTPUnauthorized): + await validator.validate("not-a-jwt") + + async def test_opaque_token_falls_back_to_introspection(self): + validator = make_jwks_validator( + **{"oauth.introspection_endpoint": INTROSPECTION_ENDPOINT} + ) + validator._introspect = mock.CoroutineMock( + return_value={"active": True, "sub": "user-123"} + ) + claims = await validator.validate("not-a-jwt") + assert claims["sub"] == "user-123" + validator._introspect.assert_awaited_once_with("not-a-jwt") + + async def test_jwks_error_without_introspection_rejected(self): + validator = make_jwks_validator() + validator._jwks_client.get_signing_key_from_jwt = mock.MagicMock( + side_effect=jwt.exceptions.PyJWKClientError("JWKS fetch failed") + ) + with self.assertRaises(web.HTTPUnauthorized): + await validator.validate(make_jwt()) + + async def test_jwks_error_falls_back_to_introspection(self): + validator = make_jwks_validator( + **{"oauth.introspection_endpoint": INTROSPECTION_ENDPOINT} + ) + validator._jwks_client.get_signing_key_from_jwt = mock.MagicMock( + side_effect=jwt.exceptions.PyJWKClientError("JWKS fetch failed") + ) + validator._introspect = mock.CoroutineMock(return_value={"active": True}) + assert await validator.validate(make_jwt()) == {"active": True} + + +class TestIntrospection(IsolatedAsyncioTestCase): + def make_introspection_validator(self, **settings): + return make_validator( + **{"oauth.introspection_endpoint": INTROSPECTION_ENDPOINT, **settings} + ) + + async def test_active_token_returns_claims(self): + validator = self.make_introspection_validator() + session = mock_session( + make_response(body={"active": True, "sub": "svc-1", "scope": "acapy:admin"}) + ) + validator._get_session = mock.MagicMock(return_value=session) + claims = await validator.validate("opaque-token") + assert claims["sub"] == "svc-1" + session.post.assert_called_once_with( + INTROSPECTION_ENDPOINT, + data={"token": "opaque-token", "token_type_hint": "access_token"}, + auth=None, + ) + + async def test_client_credentials_sent_as_basic_auth(self): + validator = self.make_introspection_validator( + **{ + "oauth.introspection_client_id": "acapy-rs", + "oauth.introspection_client_secret": "s3cret", + } + ) + session = mock_session(make_response(body={"active": True})) + validator._get_session = mock.MagicMock(return_value=session) + await validator.validate("opaque-token") + auth = session.post.call_args.kwargs["auth"] + assert auth == aiohttp.BasicAuth("acapy-rs", "s3cret") + + async def test_inactive_token_rejected(self): + validator = self.make_introspection_validator() + validator._get_session = mock.MagicMock( + return_value=mock_session(make_response(body={"active": False})) + ) + with self.assertRaises(web.HTTPUnauthorized): + await validator.validate("opaque-token") + + async def test_non_200_response_rejected(self): + validator = self.make_introspection_validator() + validator._get_session = mock.MagicMock( + return_value=mock_session(make_response(status=503)) + ) + with self.assertRaises(web.HTTPUnauthorized): + await validator.validate("opaque-token") + + async def test_network_error_returns_service_unavailable(self): + validator = self.make_introspection_validator() + validator._get_session = mock.MagicMock( + return_value=mock_session( + post_exc=aiohttp.ClientConnectionError("connection refused") + ) + ) + with self.assertRaises(web.HTTPServiceUnavailable): + await validator.validate("opaque-token") + + async def test_timeout_returns_service_unavailable(self): + validator = self.make_introspection_validator() + validator._get_session = mock.MagicMock( + return_value=mock_session(post_exc=asyncio.TimeoutError()) + ) + with self.assertRaises(web.HTTPServiceUnavailable): + await validator.validate("opaque-token") + + async def test_session_reused_and_closed(self): + validator = self.make_introspection_validator() + session = validator._get_session() + assert validator._get_session() is session + await validator.close() + assert session.closed + assert validator._session is None + # close is idempotent + await validator.close() + # a fresh session is created after close + replacement = validator._get_session() + assert replacement is not session + await validator.close() diff --git a/acapy_agent/config/argparse.py b/acapy_agent/config/argparse.py index e86d10ee1a..ce90e16bff 100644 --- a/acapy_agent/config/argparse.py +++ b/acapy_agent/config/argparse.py @@ -304,24 +304,170 @@ def add_arguments(self, parser: ArgumentParser): env_var="ACAPY_ADMIN_CLIENT_MAX_REQUEST_SIZE", help="Maximum client request size to admin server, in megabytes: default 1", ) + parser.add_argument( + "--oauth-enabled", + action="store_true", + env_var="ACAPY_OAUTH_ENABLED", + help=( + "Enable OAuth2 Resource Server mode. ACA-Py will accept Bearer tokens " + "issued by an external Authorization Server and enforce scope-based " + "access control. Neither --admin-api-key nor --admin-insecure-mode is " + "required when this flag is set. Usually combined with --oauth-jwks-uri " + "and/or --oauth-introspection-endpoint." + ), + ) + parser.add_argument( + "--oauth-jwks-uri", + type=str, + metavar="", + env_var="ACAPY_OAUTH_JWKS_URI", + help=( + "JWKS endpoint of the OAuth2 Authorization Server used to validate " + "JWT access tokens (e.g. https://as.example.com/.well-known/jwks.json). " + "Implicitly enables --oauth-enabled. Neither --admin-api-key nor " + "--admin-insecure-mode is required when this is set." + ), + ) + parser.add_argument( + "--oauth-issuer", + type=str, + metavar="", + env_var="ACAPY_OAUTH_ISSUER", + help="Expected 'iss' claim value in OAuth2 JWT access tokens.", + ) + parser.add_argument( + "--oauth-audience", + type=str, + metavar="", + env_var="ACAPY_OAUTH_AUDIENCE", + help="Expected 'aud' claim value in OAuth2 JWT access tokens.", + ) + parser.add_argument( + "--oauth-introspection-endpoint", + type=str, + metavar="", + env_var="ACAPY_OAUTH_INTROSPECTION_ENDPOINT", + help=( + "RFC 7662 token introspection endpoint for validating opaque access " + "tokens. Used as a fallback when JWT validation via JWKS is not " + "possible. Requires --oauth-introspection-client-id." + ), + ) + parser.add_argument( + "--oauth-introspection-client-id", + type=str, + metavar="", + env_var="ACAPY_OAUTH_INTROSPECTION_CLIENT_ID", + help="Client ID used for HTTP Basic Auth on the introspection endpoint.", + ) + parser.add_argument( + "--oauth-introspection-client-secret", + type=str, + metavar="", + env_var="ACAPY_OAUTH_INTROSPECTION_CLIENT_SECRET", + help="Client secret used for HTTP Basic Auth on the introspection endpoint.", + ) + parser.add_argument( + "--oauth-http-timeout", + type=BoundedInt(min=1, max=300), + default=10, + metavar="", + env_var="ACAPY_OAUTH_HTTP_TIMEOUT", + help=( + "Timeout in seconds for HTTP calls to the OAuth2 Authorization " + "Server (JWKS key fetches and token introspection): default 10." + ), + ) - def get_settings(self, args: Namespace): - """Extract admin settings.""" - settings = {} - if args.admin: - admin_api_key = args.admin_api_key - admin_insecure_mode = args.admin_insecure_mode + @staticmethod + def _validate_admin_auth_args( + args: Namespace, oauth_mode: bool, admin_api_key, admin_insecure_mode + ): + """Validate admin authentication argument combinations. + Raises: + ArgsParseError: if the admin auth flags are inconsistent. + + """ + if not oauth_mode: if (admin_api_key and admin_insecure_mode) or not ( admin_api_key or admin_insecure_mode ): raise ArgsParseError( "Either --admin-api-key or --admin-insecure-mode " - "must be set but not both." + "must be set but not both, unless --oauth-enabled (or " + "--oauth-jwks-uri / --oauth-introspection-endpoint) " + "is configured." ) + return + + if not ( + getattr(args, "oauth_jwks_uri", None) + or getattr(args, "oauth_introspection_endpoint", None) + ): + raise ArgsParseError( + "OAuth mode requires a token validation method: set " + "--oauth-jwks-uri and/or --oauth-introspection-endpoint." + ) + if getattr(args, "oauth_introspection_endpoint", None) and not getattr( + args, "oauth_introspection_client_id", None + ): + raise ArgsParseError( + "--oauth-introspection-endpoint requires --oauth-introspection-client-id." + ) + if getattr(args, "oauth_jwks_uri", None) and not getattr( + args, "oauth_audience", None + ): + # Without an expected audience, any signature-valid token from the + # JWKS is accepted regardless of its intended recipient, allowing + # token reuse / confused-deputy on a shared AS. + raise ArgsParseError( + "--oauth-jwks-uri requires --oauth-audience so that JWT " + "access tokens are bound to this resource server (the " + "'aud' claim is verified)." + ) + + @staticmethod + def _oauth_settings(args: Namespace, oauth_mode: bool) -> dict: + """Build the oauth.* settings map from parsed arguments.""" + settings = {} + if oauth_mode: + settings["admin.oauth_enabled"] = True + settings["oauth.http_timeout"] = getattr(args, "oauth_http_timeout", None) + + arg_to_setting = { + "oauth_jwks_uri": "oauth.jwks_uri", + "oauth_issuer": "oauth.issuer", + "oauth_audience": "oauth.audience", + "oauth_introspection_endpoint": "oauth.introspection_endpoint", + "oauth_introspection_client_id": "oauth.introspection_client_id", + "oauth_introspection_client_secret": "oauth.introspection_client_secret", + } + for arg_name, setting_key in arg_to_setting.items(): + value = getattr(args, arg_name, None) + if value: + settings[setting_key] = value + return settings + + def get_settings(self, args: Namespace): + """Extract admin settings.""" + settings = {} + if args.admin: + admin_api_key = args.admin_api_key + admin_insecure_mode = args.admin_insecure_mode + oauth_mode = bool( + getattr(args, "oauth_enabled", False) + or getattr(args, "oauth_jwks_uri", None) + or getattr(args, "oauth_introspection_endpoint", None) + ) + + self._validate_admin_auth_args( + args, oauth_mode, admin_api_key, admin_insecure_mode + ) settings["admin.admin_api_key"] = admin_api_key settings["admin.admin_insecure_mode"] = admin_insecure_mode + settings.update(self._oauth_settings(args, oauth_mode)) settings["admin.enabled"] = True settings["admin.host"] = args.admin[0] diff --git a/acapy_agent/config/tests/test_argparse.py b/acapy_agent/config/tests/test_argparse.py index 2d3a200605..c8e253c1fe 100644 --- a/acapy_agent/config/tests/test_argparse.py +++ b/acapy_agent/config/tests/test_argparse.py @@ -53,6 +53,160 @@ def test_transport_settings(self): assert settings.get("transport.outbound_configs") == ["http"] assert result.max_outbound_retry == 5 + def test_admin_settings_oauth_jwks(self): + """OAuth mode via JWKS: no API key required, oauth settings mapped.""" + parser = argparse.create_argument_parser() + group = argparse.AdminGroup() + group.add_arguments(parser) + + result = parser.parse_args( + [ + "--admin", + "0.0.0.0", + "8000", + "--oauth-jwks-uri", + "https://as.example.com/certs", + "--oauth-issuer", + "https://as.example.com/realms/test", + "--oauth-audience", + "acapy-admin", + ] + ) + settings = group.get_settings(result) + + assert settings.get("admin.oauth_enabled") is True + assert settings.get("oauth.jwks_uri") == "https://as.example.com/certs" + assert settings.get("oauth.issuer") == "https://as.example.com/realms/test" + assert settings.get("oauth.audience") == "acapy-admin" + assert settings.get("oauth.http_timeout") == 10 + assert not settings.get("admin.admin_api_key") + assert not settings.get("admin.admin_insecure_mode") + + def test_admin_settings_oauth_http_timeout_override(self): + parser = argparse.create_argument_parser() + group = argparse.AdminGroup() + group.add_arguments(parser) + + result = parser.parse_args( + [ + "--admin", + "0.0.0.0", + "8000", + "--oauth-jwks-uri", + "https://as.example.com/certs", + "--oauth-audience", + "acapy-admin", + "--oauth-http-timeout", + "5", + ] + ) + settings = group.get_settings(result) + assert settings.get("oauth.http_timeout") == 5 + + def test_admin_settings_oauth_jwks_requires_audience(self): + """JWKS validation without an audience must fail (aud binding).""" + parser = argparse.create_argument_parser() + group = argparse.AdminGroup() + group.add_arguments(parser) + + result = parser.parse_args( + [ + "--admin", + "0.0.0.0", + "8000", + "--oauth-jwks-uri", + "https://as.example.com/certs", + ] + ) + with self.assertRaises(argparse.ArgsParseError): + group.get_settings(result) + + def test_admin_settings_oauth_introspection(self): + """OAuth mode via introspection maps endpoint and client credentials.""" + parser = argparse.create_argument_parser() + group = argparse.AdminGroup() + group.add_arguments(parser) + + result = parser.parse_args( + [ + "--admin", + "0.0.0.0", + "8000", + "--oauth-introspection-endpoint", + "https://as.example.com/introspect", + "--oauth-introspection-client-id", + "acapy-rs", + "--oauth-introspection-client-secret", + "s3cret", + ] + ) + settings = group.get_settings(result) + + assert settings.get("admin.oauth_enabled") is True + assert ( + settings.get("oauth.introspection_endpoint") + == "https://as.example.com/introspect" + ) + assert settings.get("oauth.introspection_client_id") == "acapy-rs" + assert settings.get("oauth.introspection_client_secret") == "s3cret" + + def test_admin_settings_oauth_enabled_without_validation_method(self): + """--oauth-enabled alone must fail: no way to validate tokens.""" + parser = argparse.create_argument_parser() + group = argparse.AdminGroup() + group.add_arguments(parser) + + result = parser.parse_args(["--admin", "0.0.0.0", "8000", "--oauth-enabled"]) + with self.assertRaises(argparse.ArgsParseError): + group.get_settings(result) + + def test_admin_settings_oauth_introspection_requires_client_id(self): + parser = argparse.create_argument_parser() + group = argparse.AdminGroup() + group.add_arguments(parser) + + result = parser.parse_args( + [ + "--admin", + "0.0.0.0", + "8000", + "--oauth-introspection-endpoint", + "https://as.example.com/introspect", + ] + ) + with self.assertRaises(argparse.ArgsParseError): + group.get_settings(result) + + def test_admin_settings_api_key_xor_insecure_mode(self): + """Without OAuth, exactly one of api-key / insecure-mode is required.""" + parser = argparse.create_argument_parser() + group = argparse.AdminGroup() + group.add_arguments(parser) + + result = parser.parse_args(["--admin", "0.0.0.0", "8000"]) + with self.assertRaises(argparse.ArgsParseError): + group.get_settings(result) + + result = parser.parse_args( + [ + "--admin", + "0.0.0.0", + "8000", + "--admin-api-key", + "key", + "--admin-insecure-mode", + ] + ) + with self.assertRaises(argparse.ArgsParseError): + group.get_settings(result) + + result = parser.parse_args( + ["--admin", "0.0.0.0", "8000", "--admin-api-key", "key"] + ) + settings = group.get_settings(result) + assert settings.get("admin.admin_api_key") == "key" + assert not settings.get("admin.oauth_enabled") + def test_get_genesis_transactions_list_with_ledger_selection(self): """Test multiple ledger support related argument parsing.""" diff --git a/acapy_agent/settings/routes.py b/acapy_agent/settings/routes.py index 07098357e7..20ba5c9ba0 100644 --- a/acapy_agent/settings/routes.py +++ b/acapy_agent/settings/routes.py @@ -104,8 +104,9 @@ async def update_profile_settings(request: web.BaseRequest): async with root_profile.session() as session: multitenant_mgr = session.inject_or(BaseMultitenantManager) if multitenant_mgr: - wallet_id = context.metadata.get("wallet_id") - wallet_key = context.metadata.get("wallet_key") + _meta = getattr(context, "metadata", None) or {} + wallet_id = _meta.get("wallet_id") + wallet_key = _meta.get("wallet_key") wallet_record = await multitenant_mgr.update_wallet( wallet_id, extra_settings ) @@ -142,8 +143,9 @@ async def get_profile_settings(request: web.BaseRequest): async with root_profile.session() as session: multitenant_mgr = session.inject_or(BaseMultitenantManager) if multitenant_mgr: - wallet_id = context.metadata.get("wallet_id") - wallet_key = context.metadata.get("wallet_key") + _meta = getattr(context, "metadata", None) or {} + wallet_id = _meta.get("wallet_id") + wallet_key = _meta.get("wallet_key") wallet_record, profile = await multitenant_mgr.get_wallet_and_profile( root_profile.context, wallet_id, wallet_key ) diff --git a/acapy_agent/wallet/routes.py b/acapy_agent/wallet/routes.py index a366e87180..bb3035915a 100644 --- a/acapy_agent/wallet/routes.py +++ b/acapy_agent/wallet/routes.py @@ -9,7 +9,8 @@ from aiohttp_apispec import docs, querystring_schema, request_schema, response_schema from marshmallow import fields, validate -from ..admin.decorators.auth import tenant_authentication +from ..admin.auth_context import has_auth_wallet_id +from ..admin.decorators.auth import require_scope, tenant_authentication from ..admin.request_context import AdminRequestContext from ..config.injection_context import InjectionContext from ..connections.base_manager import BaseConnectionManager @@ -560,6 +561,7 @@ async def wallet_did_list(request: web.BaseRequest): @request_schema(DIDCreateSchema()) @response_schema(DIDResultSchema, 200, description="") @tenant_authentication +@require_scope("acapy:wallet:create", "acapy:admin") async def wallet_create_did(request: web.BaseRequest): """Request handler for creating a new local DID in the wallet. @@ -1396,7 +1398,7 @@ async def upgrade_anoncreds(request: web.BaseRequest): UPGRADING_RECORD_IN_PROGRESS, ) await storage.add_record(upgrading_record) - is_subwallet = context.metadata and "wallet_id" in context.metadata + is_subwallet = has_auth_wallet_id(context) # Create background task and store reference to prevent garbage collection task = asyncio.create_task( upgrade_wallet_to_anoncreds_if_requested(profile, is_subwallet) diff --git a/demo/demo-authserver/.gitignore b/demo/demo-authserver/.gitignore new file mode 100644 index 0000000000..4c49bd78f1 --- /dev/null +++ b/demo/demo-authserver/.gitignore @@ -0,0 +1 @@ +.env diff --git a/demo/demo-authserver/README.md b/demo/demo-authserver/README.md new file mode 100644 index 0000000000..cfae9947f7 --- /dev/null +++ b/demo/demo-authserver/README.md @@ -0,0 +1,168 @@ +# ACA-Py OAuth2 Resource Server Demo + +This demo runs ACA-Py as an OAuth2 Resource Server backed by Keycloak as the Authorization Server. Access tokens are issued by Keycloak and presented to ACA-Py — no ACA-Py API key or ACA-Py-issued JWT is involved. + +## Services + +| Service | Port | Purpose | +|---|---|---| +| `keycloak` | 8080 | Authorization Server (Keycloak 24, dev mode) | +| `wallet-db` | — (internal) | PostgreSQL for ACA-Py wallet storage | +| `acapy` | 8031 (admin), 8001 (agent) | ACA-Py configured as OAuth2 Resource Server | + +## Prerequisites + +- Docker with Compose v2 +- `curl` and `jq` (for the setup script) + +## Keycloak Realm + +The `keycloak/realm-export.json` file is imported automatically on first start. It configures: + +**Client Scopes** + +| Scope | Purpose | +|---|---| +| `acapy:admin` | Full administrative access | +| `acapy:tenant` | Tenant-level access (credentials, connections, presentations) | +| `acapy:tenant:read` | Read-only tenant access | +| `acapy:wallet:create` | Permission to create sub-wallets | + +**Clients** + +| Client ID | Grant | Default Scope | Purpose | +|---|---|---|---| +| `acapy-resource-server` | bearer-only | — | Registered RS; tokens include it in `aud` | +| `acapy-controller` | client_credentials | `acapy:admin` | Admin controller secret: `controller-secret` | +| `acapy-tenant-demo` | client_credentials | `acapy:tenant` | Demo tenant secret: `tenant-secret` | + +The `acapy-tenant-demo` client includes a `wallet_id` hardcoded claim (initially set to `PLACEHOLDER_WALLET_ID`). The setup script replaces this with a real wallet UUID after provisioning. + +## Quick Start + +**Step 1 — Start all services** + +```bash +cd demo/demo-authserver +docker compose up --build +``` + +Wait until all three services report healthy. This typically takes 60–90 seconds on first run while Keycloak initialises. + +**Step 2 — Provision a demo tenant** + +In a second terminal, from the `demo/demo-authserver` directory: + +```bash +./scripts/setup-tenant.sh +``` + +The script: +1. Waits for Keycloak and ACA-Py to be ready +2. Obtains a Keycloak admin token +3. Obtains an ACA-Py admin token using the `acapy-controller` client credentials +4. Creates a sub-wallet via `POST /multitenancy/wallet` +5. Updates the `wallet_id` hardcoded claim on the `acapy-tenant-demo` Keycloak client to the new wallet's UUID + +On success it prints the token commands to use for the next steps. + +## Trying It Out + +### Get an admin token + +```bash +ADMIN_TOKEN=$(curl -s -X POST \ + 'http://localhost:8080/realms/acapy/protocol/openid-connect/token' \ + -d 'grant_type=client_credentials' \ + -d 'client_id=acapy-controller' \ + -d 'client_secret=controller-secret' \ + | jq -r .access_token) +``` + +Inspect the token to see the `acapy:admin` scope and `acapy-resource-server` audience: + +```bash +echo $ADMIN_TOKEN | cut -d. -f2 | base64 -d 2>/dev/null | jq '{scope, aud, sub}' +``` + +### Call ACA-Py with the admin token + +```bash +# List wallets (admin-only) +curl -s -H "Authorization: Bearer $ADMIN_TOKEN" \ + http://localhost:8031/multitenancy/wallets | jq . + +# Check server status +curl -s -H "Authorization: Bearer $ADMIN_TOKEN" \ + http://localhost:8031/status | jq . +``` + +### Get a tenant token + +```bash +TENANT_TOKEN=$(curl -s -X POST \ + 'http://localhost:8080/realms/acapy/protocol/openid-connect/token' \ + -d 'grant_type=client_credentials' \ + -d 'client_id=acapy-tenant-demo' \ + -d 'client_secret=tenant-secret' \ + | jq -r .access_token) +``` + +The tenant token will contain `acapy:tenant` scope and the `wallet_id` claim set to the provisioned wallet's UUID. ACA-Py uses that claim to route the request to the correct sub-wallet. + +```bash +# List connections for the tenant wallet +curl -s -H "Authorization: Bearer $TENANT_TOKEN" \ + http://localhost:8031/connections | jq . +``` + +### OpenAPI / Swagger UI + +Browse to [http://localhost:8031/api/doc](http://localhost:8031/api/doc). Click **Authorize** and paste a bearer token (without the `Bearer ` prefix) to authenticate Swagger requests. + +## Configuration + +All defaults are in `.env`. Override them by editing the file or exporting variables before running `docker compose up`. + +| Variable | Default | Description | +|---|---|---| +| `KEYCLOAK_ADMIN` | `admin` | Keycloak admin username | +| `KEYCLOAK_ADMIN_PASSWORD` | `admin` | Keycloak admin password | +| `KEYCLOAK_PORT` | `8080` | Host port for Keycloak | +| `POSTGRES_USER` | `acapy` | PostgreSQL username | +| `POSTGRES_PASSWORD` | `acapy-secret` | PostgreSQL password | +| `ACAPY_ADMIN_PORT` | `8031` | Host port for ACA-Py admin API | +| `ACAPY_HTTP_PORT` | `8001` | Host port for ACA-Py agent transport | +| `ACAPY_OAUTH_AUDIENCE` | `acapy-resource-server` | Expected `aud` claim in access tokens | +| `ACAPY_WALLET_KEY` | `demo-base-wallet-key` | Base wallet encryption key | +| `ACAPY_LOG_LEVEL` | `info` | ACA-Py log level | + +## Adding More Tenants + +To provision additional tenants: + +1. Create a new confidential client in Keycloak (via the admin console at [http://localhost:8080](http://localhost:8080)) with: + - Service accounts enabled + - Default scope: `acapy:tenant` + - An audience mapper pointing to `acapy-resource-server` + - A `wallet_id` hardcoded claim (set after wallet creation) +2. Create a sub-wallet via `POST /multitenancy/wallet` using an admin token. +3. Update the client's `wallet_id` claim to the returned wallet UUID. + +Or use the `setup-tenant.sh` script as a template — it performs exactly these steps for the `acapy-tenant-demo` client. + +## Resetting the Demo + +```bash +docker compose down -v # removes all containers and the wallet-db volume +docker compose up --build +./scripts/setup-tenant.sh +``` + +The Keycloak realm is reimported from `keycloak/realm-export.json` on each fresh start (Keycloak 24 dev mode uses an in-memory H2 database). + +## Token Validation Notes + +- ACA-Py validates JWT access tokens locally by fetching signing keys from Keycloak's JWKS endpoint (`/realms/acapy/protocol/openid-connect/certs`). No round-trip to Keycloak occurs on every request — keys are cached by `PyJWKClient` and only re-fetched when a new `kid` is seen. +- The `--jwt-secret` startup parameter is still required by the multitenant subsystem but is **not used** for token issuance or validation when OAuth mode is active. +- Only **managed wallets** (`key_management_mode: managed`) work in OAuth mode. Unmanaged wallets require the wallet key to be passed in the token, which is incompatible with external AS-issued tokens. diff --git a/demo/demo-authserver/docker-compose.yml b/demo/demo-authserver/docker-compose.yml new file mode 100644 index 0000000000..b652c11af7 --- /dev/null +++ b/demo/demo-authserver/docker-compose.yml @@ -0,0 +1,102 @@ +# Demo: ACA-Py as an OAuth2 Resource Server backed by Keycloak +# +# Services: +# keycloak - Authorization Server (Keycloak 24, dev mode, pre-loaded realm) +# wallet-db - PostgreSQL for ACA-Py wallet storage +# acapy - ACA-Py agent configured as an OAuth2 Resource Server +# +# Quick start: +# docker compose up --build +# ./scripts/setup-tenant.sh (in a second terminal, once all services are healthy) + +networks: + demo-net: + name: acapy-oauth-demo + driver: bridge + +services: + + keycloak: + image: quay.io/keycloak/keycloak:24.0.5 + command: start-dev --import-realm + environment: + KEYCLOAK_ADMIN: ${KEYCLOAK_ADMIN:-admin} + KEYCLOAK_ADMIN_PASSWORD: ${KEYCLOAK_ADMIN_PASSWORD:-admin} + KC_HTTP_PORT: 8080 + # Fix iss claim to always use the host-accessible URL so ACA-Py's + # --oauth-issuer check matches tokens obtained by scripts running on the host. + KC_HOSTNAME: localhost + KC_HOSTNAME_PORT: "${KEYCLOAK_PORT:-8080}" + KC_HOSTNAME_STRICT: "false" + KC_HOSTNAME_STRICT_HTTPS: "false" + KC_HTTP_ENABLED: "true" + KC_HEALTH_ENABLED: "true" + ports: + - "${KEYCLOAK_PORT:-8080}:8080" + volumes: + - ./keycloak/realm-export.json:/opt/keycloak/data/import/realm-export.json:ro + networks: + - demo-net + healthcheck: + test: ["CMD-SHELL", "curl -sf http://localhost:8080/health/ready"] + interval: 10s + timeout: 5s + retries: 20 + start_period: 30s + + wallet-db: + image: postgres:16 + environment: + POSTGRES_USER: ${POSTGRES_USER:-acapy} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-acapy-secret} + POSTGRES_DB: ${POSTGRES_DB:-acapy} + volumes: + - wallet-db-data:/var/lib/postgresql/data + networks: + - demo-net + healthcheck: + test: ["CMD", "pg_isready", "-U", "acapy"] + interval: 5s + timeout: 5s + retries: 10 + + acapy: + build: + context: ../.. + dockerfile: docker/Dockerfile.run + args: + all_extras: "" # empty disables --all-extras (avoids BBS+ which has no ARM build) + depends_on: + wallet-db: + condition: service_healthy + keycloak: + condition: service_started + environment: + POSTGRES_USER: ${POSTGRES_USER:-acapy} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-acapy-secret} + POSTGRES_HOST: wallet-db + POSTGRES_PORT: 5432 + KEYCLOAK_REALM_URL: http://keycloak:8080/realms/acapy + ACAPY_OAUTH_ISSUER: http://localhost:${KEYCLOAK_PORT:-8080}/realms/${KEYCLOAK_REALM:-acapy} + ACAPY_OAUTH_AUDIENCE: ${ACAPY_OAUTH_AUDIENCE:-acapy-resource-server} + ACAPY_JWT_SECRET: ${ACAPY_JWT_SECRET:-demo-jwt-secret-unused-in-oauth-mode} + ACAPY_WALLET_KEY: ${ACAPY_WALLET_KEY:-demo-base-wallet-key} + ACAPY_LOG_LEVEL: ${ACAPY_LOG_LEVEL:-info} + ports: + - "${ACAPY_ADMIN_PORT:-8031}:8031" + - "${ACAPY_HTTP_PORT:-8001}:8001" + entrypoint: /bin/bash + command: ["/usr/src/app/start-acapy.sh"] + volumes: + - ./scripts/start-acapy.sh:/usr/src/app/start-acapy.sh:ro + networks: + - demo-net + healthcheck: + test: ["CMD-SHELL", "curl -sf http://localhost:8031/status/ready"] + interval: 10s + timeout: 5s + retries: 15 + start_period: 20s + +volumes: + wallet-db-data: diff --git a/demo/demo-authserver/keycloak/realm-export.json b/demo/demo-authserver/keycloak/realm-export.json new file mode 100644 index 0000000000..2dc3ba7126 --- /dev/null +++ b/demo/demo-authserver/keycloak/realm-export.json @@ -0,0 +1,198 @@ +{ + "realm": "acapy", + "displayName": "ACA-Py Demo", + "enabled": true, + "sslRequired": "none", + "registrationAllowed": false, + "loginWithEmailAllowed": false, + "accessTokenLifespan": 300, + "ssoSessionIdleTimeout": 1800, + "clientScopes": [ + { + "name": "acapy:admin", + "description": "Full ACA-Py administrative access — wallet management, server config, ledger", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "true", + "display.on.consent.screen": "false" + }, + "protocolMappers": [] + }, + { + "name": "acapy:tenant", + "description": "ACA-Py tenant-level access — credentials, connections, presentations", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "true", + "display.on.consent.screen": "false" + }, + "protocolMappers": [] + }, + { + "name": "acapy:tenant:read", + "description": "Read-only ACA-Py tenant access", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "true", + "display.on.consent.screen": "false" + }, + "protocolMappers": [] + }, + { + "name": "acapy:wallet:create", + "description": "Permission to create ACA-Py sub-wallets", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "true", + "display.on.consent.screen": "false" + }, + "protocolMappers": [] + } + ], + "clients": [ + { + "clientId": "acapy-resource-server", + "name": "ACA-Py Resource Server", + "description": "Registered client representing the ACA-Py admin API. Bearer-only — does not issue tokens.", + "enabled": true, + "protocol": "openid-connect", + "bearerOnly": true, + "publicClient": false, + "serviceAccountsEnabled": false, + "standardFlowEnabled": false, + "implicitFlowEnabled": false, + "directAccessGrantsEnabled": false + }, + { + "clientId": "acapy-controller", + "name": "ACA-Py Admin Controller", + "description": "Confidential service-account client for administrative access. Uses client_credentials grant.", + "enabled": true, + "protocol": "openid-connect", + "bearerOnly": false, + "publicClient": false, + "serviceAccountsEnabled": true, + "standardFlowEnabled": false, + "implicitFlowEnabled": false, + "directAccessGrantsEnabled": false, + "clientAuthenticatorType": "client-secret", + "secret": "controller-secret", + "defaultClientScopes": [ + "acapy:admin" + ], + "optionalClientScopes": [ + "acapy:tenant:read", + "acapy:wallet:create" + ], + "protocolMappers": [ + { + "name": "audience-acapy", + "protocol": "openid-connect", + "protocolMapper": "oidc-audience-mapper", + "consentRequired": false, + "config": { + "included.client.audience": "acapy-resource-server", + "id.token.claim": "false", + "access.token.claim": "true" + } + } + ] + }, + { + "clientId": "acapy-tenant-demo", + "name": "ACA-Py Demo Tenant", + "description": "Demo tenant client with full write access including DID creation. The wallet_id claim is updated by setup-tenant.sh.", + "enabled": true, + "protocol": "openid-connect", + "bearerOnly": false, + "publicClient": false, + "serviceAccountsEnabled": true, + "standardFlowEnabled": false, + "implicitFlowEnabled": false, + "directAccessGrantsEnabled": false, + "clientAuthenticatorType": "client-secret", + "secret": "tenant-secret", + "defaultClientScopes": [ + "acapy:tenant", + "acapy:wallet:create" + ], + "optionalClientScopes": [ + "acapy:tenant:read" + ], + "protocolMappers": [ + { + "name": "audience-acapy", + "protocol": "openid-connect", + "protocolMapper": "oidc-audience-mapper", + "consentRequired": false, + "config": { + "included.client.audience": "acapy-resource-server", + "id.token.claim": "false", + "access.token.claim": "true" + } + }, + { + "name": "wallet-id", + "protocol": "openid-connect", + "protocolMapper": "oidc-hardcoded-claim-mapper", + "consentRequired": false, + "config": { + "claim.name": "wallet_id", + "claim.value": "PLACEHOLDER_WALLET_ID", + "jsonType.label": "String", + "id.token.claim": "false", + "access.token.claim": "true", + "userinfo.token.claim": "false" + } + } + ] + } + , + { + "clientId": "acapy-tenant-limited", + "name": "ACA-Py Limited Tenant", + "description": "Tenant client with acapy:tenant but WITHOUT acapy:wallet:create — used to demonstrate require_scope enforcement on /wallet/did/create.", + "enabled": true, + "protocol": "openid-connect", + "bearerOnly": false, + "publicClient": false, + "serviceAccountsEnabled": true, + "standardFlowEnabled": false, + "implicitFlowEnabled": false, + "directAccessGrantsEnabled": false, + "clientAuthenticatorType": "client-secret", + "secret": "limited-secret", + "defaultClientScopes": [ + "acapy:tenant" + ], + "optionalClientScopes": [], + "protocolMappers": [ + { + "name": "audience-acapy", + "protocol": "openid-connect", + "protocolMapper": "oidc-audience-mapper", + "consentRequired": false, + "config": { + "included.client.audience": "acapy-resource-server", + "id.token.claim": "false", + "access.token.claim": "true" + } + }, + { + "name": "wallet-id", + "protocol": "openid-connect", + "protocolMapper": "oidc-hardcoded-claim-mapper", + "consentRequired": false, + "config": { + "claim.name": "wallet_id", + "claim.value": "PLACEHOLDER_WALLET_ID", + "jsonType.label": "String", + "id.token.claim": "false", + "access.token.claim": "true", + "userinfo.token.claim": "false" + } + } + ] + } + ] +} diff --git a/demo/demo-authserver/oauth-scope-test-report.md b/demo/demo-authserver/oauth-scope-test-report.md new file mode 100644 index 0000000000..51716f897f --- /dev/null +++ b/demo/demo-authserver/oauth-scope-test-report.md @@ -0,0 +1,64 @@ +# OAuth Scope Test Report + +**Date:** 2026-05-26 18:58:41 +**ACA-Py:** http://localhost:8031 +**Keycloak realm:** http://localhost:8080/realms/acapy +**Wallet ID under test:** b3ea2232-f617-4f95-b90c-35409d2b8d44 + +## Summary + +| Result | Count | +|--------|-------| +| Passed | 31 | +| Failed | 0 | +| Total | 31 | + +## Test Cases + +| Group | Test | Expected | Actual | Result | +|-------|------|----------|--------|--------| +| Public | GET /status/ready — no token | 200 | 200 | PASS | +| Public | GET /status/live — no token | 200 | 200 | PASS | +| No token | GET /multitenancy/wallets — no token | 401 | 401 | PASS | +| No token | GET /connections — no token | 401 | 401 | PASS | +| No token | GET /credentials — no token | 401 | 401 | PASS | +| Invalid token | GET /multitenancy/wallets — invalid token | 401 | 401 | PASS | +| Invalid token | GET /connections — invalid token | 401 | 401 | PASS | +| Admin GET | GET /status/ready — admin token | 200 | 200 | PASS | +| Admin GET | GET /status/config — admin token | 200 | 200 | PASS | +| Admin GET | GET /multitenancy/wallets — admin token | 200 | 200 | PASS | +| Admin GET | GET /connections — admin token | 200 | 200 | PASS | +| Admin GET | GET /credentials — admin token | 200 | 200 | PASS | +| Admin GET | GET /multitenancy/wallet/{id} — admin token | 200 | 200 | PASS | +| Admin POST | POST /wallet/did/create — admin token | 200 | 200 | PASS | +| Admin POST | POST /multitenancy/wallet/{id}/remove — admin token | 200 | 200 | PASS | +| Tenant GET | GET /connections — tenant token | 200 | 200 | PASS | +| Tenant GET | GET /credentials — tenant token | 200 | 200 | PASS | +| Tenant GET | GET /wallet/did — tenant token | 200 | 200 | PASS | +| Tenant POST | POST /wallet/did/create — tenant token | 200 | 200 | PASS | +| Tenant→Admin | GET /multitenancy/wallets — tenant token | 403 | 403 | PASS | +| Tenant→Admin | GET /multitenancy/wallet/{id} — tenant token | 403 | 403 | PASS | +| Tenant→Admin | GET /status/config — tenant token | 403 | 403 | PASS | +| Read-only | GET /connections — readonly token | 200 | 200 | PASS | +| Read-only | GET /credentials — readonly token | 200 | 200 | PASS | +| Read-only | GET /wallet/did — readonly token | 200 | 200 | PASS | +| Read-only | POST /wallet/did/create — readonly token | 403 | 403 | PASS | +| Read-only | GET /multitenancy/wallets — readonly token | 403 | 403 | PASS | +| wallet:create scope | GET /connections — limited token (acapy:tenant, no wallet:create) | 200 | 200 | PASS | +| wallet:create scope | POST /wallet/did/create — limited token (missing acapy:wallet:create) | 403 | 403 | PASS | +| wallet:create scope | POST /wallet/did/create — tenant token (has acapy:wallet:create) | 200 | 200 | PASS | +| wallet:create scope | POST /wallet/did/create — admin token (acapy:admin satisfies require_scope) | 200 | 200 | PASS | + +## Scope Matrix + +| Endpoint | No token | Invalid token | acapy:admin | acapy:tenant + acapy:wallet:create | acapy:tenant (no wallet:create) | acapy:tenant:read | +|----------|----------|---------------|-------------|-------------------------------------|----------------------------------|-------------------| +| `GET /status/ready` | 200 | 200 | 200 | 200 | 200 | 200 | +| `GET /status/config` | 401 | 401 | 200 | 403 | 403 | 403 | +| `GET /multitenancy/wallets` | 401 | 401 | 200 | 403 | 403 | 403 | +| `GET /multitenancy/wallet/{id}` | 401 | 401 | 200 | 403 | 403 | 403 | +| `GET /connections` | 401 | 401 | 200 | 200 | 200 | 200 | +| `GET /credentials` | 401 | 401 | 200 | 200 | 200 | 200 | +| `GET /wallet/did` | 401 | 401 | 200 | 200 | 200 | 200 | +| `POST /wallet/did/create` | 401 | 401 | 200 | 200 | **403** | 403 | +| `POST /multitenancy/wallet/{id}/remove` | 401 | 401 | 200 | 403 | 403 | 403 | diff --git a/demo/demo-authserver/scripts/get-admin-token.sh b/demo/demo-authserver/scripts/get-admin-token.sh new file mode 100755 index 0000000000..ed97c4f226 --- /dev/null +++ b/demo/demo-authserver/scripts/get-admin-token.sh @@ -0,0 +1,52 @@ +#!/bin/bash +# Gets an admin access token from Keycloak using client credentials +# (acapy-controller service account, acapy:admin scope). +# +# Usage: +# ./scripts/get-admin-token.sh +# +# Requires: curl, jq +set -euo pipefail + +if [[ -f "$(dirname "$0")/../.env" ]]; then + set -o allexport + source "$(dirname "$0")/../.env" + set +o allexport +fi + +KEYCLOAK_URL=${KEYCLOAK_URL:-http://localhost:8080} +KEYCLOAK_REALM=${KEYCLOAK_REALM:-acapy} +CONTROLLER_CLIENT_ID=${CONTROLLER_CLIENT_ID:-acapy-controller} +CONTROLLER_CLIENT_SECRET=${CONTROLLER_CLIENT_SECRET:-controller-secret} +ACAPY_URL=${ACAPY_URL:-http://localhost:8031} + +TOKEN_ENDPOINT="${KEYCLOAK_URL}/realms/${KEYCLOAK_REALM}/protocol/openid-connect/token" + +TOKEN_RESPONSE=$(curl -sf -X POST "${TOKEN_ENDPOINT}" \ + -H "Content-Type: application/x-www-form-urlencoded" \ + -d "grant_type=client_credentials" \ + -d "client_id=${CONTROLLER_CLIENT_ID}" \ + -d "client_secret=${CONTROLLER_CLIENT_SECRET}") + +ACCESS_TOKEN=$(echo "${TOKEN_RESPONSE}" | jq -r '.access_token') + +echo "" +echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" +echo " Admin token (${CONTROLLER_CLIENT_ID})" +echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" +echo "" +echo " Claims:" +echo "${ACCESS_TOKEN}" \ + | cut -d. -f2 \ + | tr '_-' '/+' \ + | awk '{l=length($0)%4; if(l==2) print $0"=="; else if(l==3) print $0"="; else print $0}' \ + | base64 -d 2>/dev/null \ + | jq '{sub, scope, aud, exp}' +echo "" +echo " Access token:" +echo "${ACCESS_TOKEN}" +echo "" +echo " Test against ACA-Py:" +echo " curl -s -H \"Authorization: Bearer ${ACCESS_TOKEN}\" \\" +echo " ${ACAPY_URL}/multitenancy/wallets | jq ." +echo "" diff --git a/demo/demo-authserver/scripts/get-tenant-token.sh b/demo/demo-authserver/scripts/get-tenant-token.sh new file mode 100755 index 0000000000..c7448fb0f6 --- /dev/null +++ b/demo/demo-authserver/scripts/get-tenant-token.sh @@ -0,0 +1,54 @@ +#!/bin/bash +# Gets a tenant access token from Keycloak using client credentials +# (confidential client, server-to-server). The client must have a wallet_id +# claim configured — run ./scripts/setup-tenant.sh first. +# +# Usage: +# ./scripts/get-tenant-token.sh [client_id] [client_secret] +# +# Requires: curl, jq +set -euo pipefail + +if [[ -f "$(dirname "$0")/../.env" ]]; then + set -o allexport + source "$(dirname "$0")/../.env" + set +o allexport +fi + +KEYCLOAK_URL=${KEYCLOAK_URL:-http://localhost:8080} +KEYCLOAK_REALM=${KEYCLOAK_REALM:-acapy} +ACAPY_URL=${ACAPY_URL:-http://localhost:8031} + +CLIENT_ID="${1:-${TENANT_CLIENT_ID:-acapy-tenant-demo}}" +CLIENT_SECRET="${2:-${TENANT_CLIENT_SECRET:-tenant-secret}}" + +TOKEN_ENDPOINT="${KEYCLOAK_URL}/realms/${KEYCLOAK_REALM}/protocol/openid-connect/token" + +TOKEN_RESPONSE=$(curl -sf -X POST "${TOKEN_ENDPOINT}" \ + -H "Content-Type: application/x-www-form-urlencoded" \ + -d "grant_type=client_credentials" \ + -d "client_id=${CLIENT_ID}" \ + -d "client_secret=${CLIENT_SECRET}") + +ACCESS_TOKEN=$(echo "${TOKEN_RESPONSE}" | jq -r '.access_token') + +echo "" +echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" +echo " Tenant token (${CLIENT_ID})" +echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" +echo "" +echo " Claims:" +echo "${ACCESS_TOKEN}" \ + | cut -d. -f2 \ + | tr '_-' '/+' \ + | awk '{l=length($0)%4; if(l==2) print $0"=="; else if(l==3) print $0"="; else print $0}' \ + | base64 -d 2>/dev/null \ + | jq '{sub, scope, wallet_id, aud, exp}' +echo "" +echo " Access token:" +echo "${ACCESS_TOKEN}" +echo "" +echo " Test against ACA-Py:" +echo " curl -s -H \"Authorization: Bearer ${ACCESS_TOKEN}\" \\" +echo " ${ACAPY_URL}/connections | jq ." +echo "" diff --git a/demo/demo-authserver/scripts/get-user-token.sh b/demo/demo-authserver/scripts/get-user-token.sh new file mode 100755 index 0000000000..a1c9bbb838 --- /dev/null +++ b/demo/demo-authserver/scripts/get-user-token.sh @@ -0,0 +1,273 @@ +#!/bin/bash +# Authenticates a demo user via Keycloak using authorization code + PKCE +# (front-channel, public client — no client secret needed). +# +# On first run it will: +# - Create a public OIDC client 'acapy-user-login' (auth code + PKCE) +# - Create a test user with configurable credentials +# +# Subsequent runs skip creation and re-authenticate. +# +# Usage: +# ./scripts/get-user-token.sh [username] [password] +# +# Requires: curl, jq, python3, openssl +set -euo pipefail + +# ── config ──────────────────────────────────────────────────────────────────── + +if [[ -f "$(dirname "$0")/../.env" ]]; then + set -o allexport + source "$(dirname "$0")/../.env" + set +o allexport +fi + +KEYCLOAK_URL=${KEYCLOAK_URL:-http://localhost:8080} +KEYCLOAK_REALM=${KEYCLOAK_REALM:-acapy} +KEYCLOAK_ADMIN=${KEYCLOAK_ADMIN:-admin} +KEYCLOAK_ADMIN_PASSWORD=${KEYCLOAK_ADMIN_PASSWORD:-admin} +TENANT_CLIENT_ID=${TENANT_CLIENT_ID:-acapy-tenant-demo} +ACAPY_URL=${ACAPY_URL:-http://localhost:8031} + +USER_LOGIN_CLIENT_ID="acapy-user-login" +TEST_USERNAME="${1:-demo-user}" +TEST_PASSWORD="${2:-demo-password}" +TOKEN_ENDPOINT="${KEYCLOAK_URL}/realms/${KEYCLOAK_REALM}/protocol/openid-connect/token" + +CALLBACK_PORT=9999 +REDIRECT_URI="http://localhost:${CALLBACK_PORT}/callback" + +# ── helpers ─────────────────────────────────────────────────────────────────── + +wait_for() { + local label="$1" url="$2" + echo -n "==> Waiting for ${label}..." + until curl -sf "${url}" > /dev/null 2>&1; do + echo -n "." + sleep 3 + done + echo " ready." +} + +# ── wait ────────────────────────────────────────────────────────────────────── + +wait_for "Keycloak" "${KEYCLOAK_URL}/health/ready" + +# ── Keycloak admin token ────────────────────────────────────────────────────── + +KC_ADMIN_TOKEN=$(curl -sf -X POST \ + "${KEYCLOAK_URL}/realms/master/protocol/openid-connect/token" \ + -H "Content-Type: application/x-www-form-urlencoded" \ + -d "grant_type=password" \ + -d "client_id=admin-cli" \ + -d "username=${KEYCLOAK_ADMIN}" \ + -d "password=${KEYCLOAK_ADMIN_PASSWORD}" \ + | jq -r '.access_token') + +ADMIN_H="Authorization: Bearer ${KC_ADMIN_TOKEN}" +REALM_URL="${KEYCLOAK_URL}/admin/realms/${KEYCLOAK_REALM}" + +# ── read wallet_id from Keycloak (set by setup-tenant.sh) ──────────────────── + +KC_CLIENT_UUID=$(curl -sf \ + "${REALM_URL}/clients?clientId=${TENANT_CLIENT_ID}" \ + -H "${ADMIN_H}" | jq -r '.[0].id // empty') + +WALLET_ID=$(curl -sf \ + "${REALM_URL}/clients/${KC_CLIENT_UUID}/protocol-mappers/models" \ + -H "${ADMIN_H}" \ + | jq -r '.[] | select(.name == "wallet-id") | .config["claim.value"] // empty') + +if [[ -z "${WALLET_ID}" || "${WALLET_ID}" == "PLACEHOLDER_WALLET_ID" ]]; then + echo "ERROR: wallet_id not set on '${TENANT_CLIENT_ID}'. Run ./scripts/setup-tenant.sh first." >&2 + exit 1 +fi + +echo "==> Using wallet_id: ${WALLET_ID}" + +# ── create user-login client if it doesn't exist ───────────────────────────── + +EXISTING=$(curl -sf \ + "${REALM_URL}/clients?clientId=${USER_LOGIN_CLIENT_ID}" \ + -H "${ADMIN_H}" | jq -r '.[0].id // empty') + +if [[ -z "${EXISTING}" ]]; then + echo "==> Creating client '${USER_LOGIN_CLIENT_ID}'..." + curl -sf -X POST "${REALM_URL}/clients" \ + -H "${ADMIN_H}" \ + -H "Content-Type: application/json" \ + -d "{ + \"clientId\": \"${USER_LOGIN_CLIENT_ID}\", + \"name\": \"ACA-Py User Login\", + \"enabled\": true, + \"publicClient\": true, + \"standardFlowEnabled\": true, + \"directAccessGrantsEnabled\": false, + \"serviceAccountsEnabled\": false, + \"protocol\": \"openid-connect\", + \"redirectUris\": [\"http://localhost:${CALLBACK_PORT}/*\"], + \"webOrigins\": [\"+\"], + \"defaultClientScopes\": [\"acapy:tenant\"], + \"optionalClientScopes\": [], + \"protocolMappers\": [ + { + \"name\": \"audience-acapy\", + \"protocol\": \"openid-connect\", + \"protocolMapper\": \"oidc-audience-mapper\", + \"consentRequired\": false, + \"config\": { + \"included.client.audience\": \"acapy-resource-server\", + \"id.token.claim\": \"false\", + \"access.token.claim\": \"true\" + } + }, + { + \"name\": \"wallet-id\", + \"protocol\": \"openid-connect\", + \"protocolMapper\": \"oidc-hardcoded-claim-mapper\", + \"consentRequired\": false, + \"config\": { + \"claim.name\": \"wallet_id\", + \"claim.value\": \"${WALLET_ID}\", + \"jsonType.label\": \"String\", + \"id.token.claim\": \"false\", + \"access.token.claim\": \"true\", + \"userinfo.token.claim\": \"false\" + } + } + ] + }" + echo " Client created." +else + echo "==> Client '${USER_LOGIN_CLIENT_ID}' already exists." +fi + +# ── create test user if they don't exist ───────────────────────────────────── + +EXISTING_USER=$(curl -sf \ + "${REALM_URL}/users?username=${TEST_USERNAME}&exact=true" \ + -H "${ADMIN_H}" | jq -r '.[0].id // empty') + +if [[ -z "${EXISTING_USER}" ]]; then + echo "==> Creating user '${TEST_USERNAME}'..." + curl -sf -X POST "${REALM_URL}/users" \ + -H "${ADMIN_H}" \ + -H "Content-Type: application/json" \ + -d "{ + \"username\": \"${TEST_USERNAME}\", + \"email\": \"${TEST_USERNAME}@demo.local\", + \"firstName\": \"Demo\", + \"lastName\": \"User\", + \"enabled\": true, + \"emailVerified\": true, + \"credentials\": [{ + \"type\": \"password\", + \"value\": \"${TEST_PASSWORD}\", + \"temporary\": false + }] + }" + echo " User created." +else + echo "==> User '${TEST_USERNAME}' already exists." +fi + +# ── PKCE ────────────────────────────────────────────────────────────────────── + +CODE_VERIFIER=$(openssl rand -base64 48 | tr -d '/+=' | head -c 43) +CODE_CHALLENGE=$(printf '%s' "${CODE_VERIFIER}" \ + | openssl dgst -sha256 -binary \ + | base64 | tr -d '=' | tr '+/' '-_') + +# ── build auth URL and prompt user ─────────────────────────────────────────── + +AUTH_URL="${KEYCLOAK_URL}/realms/${KEYCLOAK_REALM}/protocol/openid-connect/auth" +AUTH_URL+="?client_id=${USER_LOGIN_CLIENT_ID}" +AUTH_URL+="&response_type=code" +AUTH_URL+="&redirect_uri=$(python3 -c "import urllib.parse; print(urllib.parse.quote('${REDIRECT_URI}', safe=''))")" +AUTH_URL+="&scope=acapy%3Atenant" +AUTH_URL+="&code_challenge=${CODE_CHALLENGE}" +AUTH_URL+="&code_challenge_method=S256" + +echo "" +echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" +echo " Open this URL in your browser to log in:" +echo "" +echo " ${AUTH_URL}" +echo "" +echo " Credentials: ${TEST_USERNAME} / ${TEST_PASSWORD}" +echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" +echo "" +echo "==> Waiting for callback on ${REDIRECT_URI}..." + +# ── local callback receiver ─────────────────────────────────────────────────── + +AUTH_CODE=$(CALLBACK_PORT=${CALLBACK_PORT} python3 -c " +import http.server, urllib.parse, threading, os, sys + +port = int(os.environ['CALLBACK_PORT']) +result = [] + +class Handler(http.server.BaseHTTPRequestHandler): + def do_GET(self): + params = urllib.parse.parse_qs(urllib.parse.urlparse(self.path).query) + code = params.get('code', [None])[0] + self.send_response(200) + self.send_header('Content-Type', 'text/html') + self.end_headers() + self.wfile.write(b'

Authentication complete!

You can close this tab.

') + result.append(code or '') + threading.Thread(target=self.server.shutdown, daemon=True).start() + def log_message(self, *args): pass + +server = http.server.HTTPServer(('localhost', port), Handler) +server.serve_forever() +print(result[0] if result else '') +") + +if [[ -z "${AUTH_CODE}" ]]; then + echo "ERROR: No authorization code received." >&2 + exit 1 +fi + +echo "==> Authorization code received. Exchanging for token..." + +# ── token exchange ──────────────────────────────────────────────────────────── + +TOKEN_RESPONSE=$(curl -s -X POST "${TOKEN_ENDPOINT}" \ + -H "Content-Type: application/x-www-form-urlencoded" \ + -d "grant_type=authorization_code" \ + -d "client_id=${USER_LOGIN_CLIENT_ID}" \ + -d "code=${AUTH_CODE}" \ + -d "redirect_uri=${REDIRECT_URI}" \ + -d "code_verifier=${CODE_VERIFIER}") + +ACCESS_TOKEN=$(echo "${TOKEN_RESPONSE}" | jq -r '.access_token // empty') + +if [[ -z "${ACCESS_TOKEN}" ]]; then + echo "ERROR: Token exchange failed." >&2 + echo " Response: ${TOKEN_RESPONSE}" >&2 + exit 1 +fi + +# ── output ──────────────────────────────────────────────────────────────────── + +echo "" +echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" +echo " User token for '${TEST_USERNAME}'" +echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" +echo "" +echo " Claims:" +echo "${ACCESS_TOKEN}" \ + | cut -d. -f2 \ + | tr '_-' '/+' \ + | awk '{l=length($0)%4; if(l==2) print $0"=="; else if(l==3) print $0"="; else print $0}' \ + | base64 -d 2>/dev/null \ + | jq '{sub, scope, wallet_id, aud, exp}' +echo "" +echo " Access token:" +echo "${ACCESS_TOKEN}" +echo "" +echo " Test against ACA-Py:" +echo " curl -s -H \"Authorization: Bearer ${ACCESS_TOKEN}\" \\" +echo " ${ACAPY_URL}/connections | jq ." +echo "" diff --git a/demo/demo-authserver/scripts/setup-tenant.sh b/demo/demo-authserver/scripts/setup-tenant.sh new file mode 100755 index 0000000000..365a1658eb --- /dev/null +++ b/demo/demo-authserver/scripts/setup-tenant.sh @@ -0,0 +1,409 @@ +#!/bin/bash +# Provisions a demo tenant wallet in ACA-Py and wires the wallet_id +# back into the Keycloak acapy-tenant-demo client as a hardcoded claim. +# +# Run this from the host after all services are healthy: +# ./scripts/setup-tenant.sh +# +# Requires: curl, jq +set -euo pipefail + +# Load .env if present and not already set +if [[ -f "$(dirname "$0")/../.env" ]]; then + set -o allexport + source "$(dirname "$0")/../.env" + set +o allexport +fi + +KEYCLOAK_URL=${KEYCLOAK_URL:-http://localhost:8080} +KEYCLOAK_REALM=${KEYCLOAK_REALM:-acapy} +KEYCLOAK_ADMIN=${KEYCLOAK_ADMIN:-admin} +KEYCLOAK_ADMIN_PASSWORD=${KEYCLOAK_ADMIN_PASSWORD:-admin} +ACAPY_URL=${ACAPY_URL:-http://localhost:8031} +CONTROLLER_CLIENT_ID=${CONTROLLER_CLIENT_ID:-acapy-controller} +CONTROLLER_CLIENT_SECRET=${CONTROLLER_CLIENT_SECRET:-controller-secret} +TENANT_CLIENT_ID=${TENANT_CLIENT_ID:-acapy-tenant-demo} +READONLY_CLIENT_ID=${READONLY_CLIENT_ID:-acapy-tenant-readonly} +READONLY_CLIENT_SECRET=${READONLY_CLIENT_SECRET:-readonly-secret} +LIMITED_CLIENT_ID=${LIMITED_CLIENT_ID:-acapy-tenant-limited} +LIMITED_CLIENT_SECRET=${LIMITED_CLIENT_SECRET:-limited-secret} +WALLET_NAME=${WALLET_NAME:-demo-tenant} +REALM_URL="${KEYCLOAK_URL}/admin/realms/${KEYCLOAK_REALM}" + +# ── helpers ─────────────────────────────────────────────────────────────────── + +wait_for() { + local label="$1" url="$2" + echo -n "==> Waiting for ${label}..." + until curl -sf "${url}" > /dev/null 2>&1; do + echo -n "." + sleep 3 + done + echo " ready." +} + +# ── wait for services ───────────────────────────────────────────────────────── + +wait_for "Keycloak" "${KEYCLOAK_URL}/health/ready" +wait_for "ACA-Py" "${ACAPY_URL}/status/ready" + +# ── Keycloak admin token (master realm) ─────────────────────────────────────── + +echo "==> Authenticating with Keycloak admin..." +KC_ADMIN_TOKEN=$(curl -sf -X POST \ + "${KEYCLOAK_URL}/realms/master/protocol/openid-connect/token" \ + -H "Content-Type: application/x-www-form-urlencoded" \ + -d "grant_type=password" \ + -d "client_id=admin-cli" \ + -d "username=${KEYCLOAK_ADMIN}" \ + -d "password=${KEYCLOAK_ADMIN_PASSWORD}" \ + | jq -r '.access_token') + +# ── ACA-Py admin token (controller client credentials) ──────────────────────── + +echo "==> Getting ACA-Py admin access token..." +ACAPY_TOKEN=$(curl -sf -X POST \ + "${KEYCLOAK_URL}/realms/${KEYCLOAK_REALM}/protocol/openid-connect/token" \ + -H "Content-Type: application/x-www-form-urlencoded" \ + -d "grant_type=client_credentials" \ + -d "client_id=${CONTROLLER_CLIENT_ID}" \ + -d "client_secret=${CONTROLLER_CLIENT_SECRET}" \ + | jq -r '.access_token') + +# ── create sub-wallet ───────────────────────────────────────────────────────── + +echo "==> Creating sub-wallet '${WALLET_NAME}'..." +WALLET_RESPONSE=$(curl -s -X POST \ + "${ACAPY_URL}/multitenancy/wallet" \ + -H "Authorization: Bearer ${ACAPY_TOKEN}" \ + -H "Content-Type: application/json" \ + -d "{ + \"label\": \"${WALLET_NAME}\", + \"wallet_name\": \"${WALLET_NAME}\", + \"wallet_type\": \"askar\", + \"wallet_key\": \"${WALLET_NAME}-key\", + \"key_management_mode\": \"managed\" + }") +WALLET_ID=$(echo "${WALLET_RESPONSE}" | jq -r '.wallet_id // empty' 2>/dev/null || true) + +if [[ -z "${WALLET_ID}" ]]; then + echo " Create response: ${WALLET_RESPONSE}" + echo " Querying for existing wallet..." + QUERY_RESPONSE=$(curl -s \ + "${ACAPY_URL}/multitenancy/wallets?wallet_name=${WALLET_NAME}" \ + -H "Authorization: Bearer ${ACAPY_TOKEN}") + echo " Query response: ${QUERY_RESPONSE}" + WALLET_ID=$(echo "${QUERY_RESPONSE}" | jq -r '.results[0].wallet_id // empty' 2>/dev/null || true) +fi + +if [[ -z "${WALLET_ID}" ]]; then + echo "ERROR: Could not create or find wallet '${WALLET_NAME}'." >&2 + exit 1 +fi +echo " wallet_id: ${WALLET_ID}" + +# ── update Keycloak wallet_id claim ────────────────────────────────────────── + +echo "==> Updating Keycloak wallet-id claim for client '${TENANT_CLIENT_ID}'..." + +KC_CLIENT_UUID=$(curl -sf \ + "${REALM_URL}/clients?clientId=${TENANT_CLIENT_ID}" \ + -H "Authorization: Bearer ${KC_ADMIN_TOKEN}" \ + | jq -r '.[0].id // empty') + +if [[ -z "${KC_CLIENT_UUID}" ]]; then + echo "ERROR: Client '${TENANT_CLIENT_ID}' not found in Keycloak realm '${KEYCLOAK_REALM}'." >&2 + exit 1 +fi +echo " client UUID: ${KC_CLIENT_UUID}" + +# Remove any existing wallet-id mapper (handles both import-created and +# previously set by this script). Ignore 404 — mapper may not exist yet. +MAPPER_ID=$(curl -sf \ + "${REALM_URL}/clients/${KC_CLIENT_UUID}/protocol-mappers/models" \ + -H "Authorization: Bearer ${KC_ADMIN_TOKEN}" \ + | jq -r '.[] | select(.name == "wallet-id") | .id // empty') + +if [[ -n "${MAPPER_ID}" ]]; then + echo " Removing existing wallet-id mapper (${MAPPER_ID})..." + curl -sf -X DELETE \ + "${REALM_URL}/clients/${KC_CLIENT_UUID}/protocol-mappers/models/${MAPPER_ID}" \ + -H "Authorization: Bearer ${KC_ADMIN_TOKEN}" +fi + +# Create a fresh mapper with the correct wallet_id value +echo " Creating wallet-id mapper..." +CREATE_STATUS=$(curl -s -o /dev/null -w "%{http_code}" -X POST \ + "${REALM_URL}/clients/${KC_CLIENT_UUID}/protocol-mappers/models" \ + -H "Authorization: Bearer ${KC_ADMIN_TOKEN}" \ + -H "Content-Type: application/json" \ + -d "{ + \"name\": \"wallet-id\", + \"protocol\": \"openid-connect\", + \"protocolMapper\": \"oidc-hardcoded-claim-mapper\", + \"consentRequired\": false, + \"config\": { + \"claim.name\": \"wallet_id\", + \"claim.value\": \"${WALLET_ID}\", + \"jsonType.label\": \"String\", + \"id.token.claim\": \"false\", + \"access.token.claim\": \"true\", + \"userinfo.token.claim\": \"false\" + } + }") +if [[ "${CREATE_STATUS}" != "201" ]]; then + echo "ERROR: Failed to create wallet-id mapper (HTTP ${CREATE_STATUS})." >&2 + exit 1 +fi +echo " Mapper created (HTTP ${CREATE_STATUS})." + +# Verify — fetch mapper list and confirm wallet_id value is set correctly +ACTUAL_WALLET_ID=$(curl -sf \ + "${REALM_URL}/clients/${KC_CLIENT_UUID}/protocol-mappers/models" \ + -H "Authorization: Bearer ${KC_ADMIN_TOKEN}" \ + | jq -r '.[] | select(.name == "wallet-id") | .config["claim.value"] // empty') +echo " Verified claim.value in Keycloak: ${ACTUAL_WALLET_ID}" +if [[ "${ACTUAL_WALLET_ID}" != "${WALLET_ID}" ]]; then + echo "ERROR: Keycloak mapper value does not match wallet_id (${ACTUAL_WALLET_ID} != ${WALLET_ID})." >&2 + exit 1 +fi + +# ── provision read-only tenant Keycloak client ─────────────────────────────── + +echo "==> Provisioning read-only tenant client '${READONLY_CLIENT_ID}'..." + +READONLY_CLIENT_UUID=$(curl -sf \ + "${REALM_URL}/clients?clientId=${READONLY_CLIENT_ID}" \ + -H "Authorization: Bearer ${KC_ADMIN_TOKEN}" \ + | jq -r '.[0].id // empty') + +if [[ -z "${READONLY_CLIENT_UUID}" ]]; then + echo " Creating client..." + curl -sf -X POST "${REALM_URL}/clients" \ + -H "Authorization: Bearer ${KC_ADMIN_TOKEN}" \ + -H "Content-Type: application/json" \ + -d "{ + \"clientId\": \"${READONLY_CLIENT_ID}\", + \"enabled\": true, + \"publicClient\": false, + \"serviceAccountsEnabled\": true, + \"standardFlowEnabled\": false, + \"clientAuthenticatorType\": \"client-secret\", + \"secret\": \"${READONLY_CLIENT_SECRET}\", + \"defaultClientScopes\": [\"acapy:tenant:read\"], + \"optionalClientScopes\": [], + \"protocolMappers\": [ + { + \"name\": \"audience-acapy\", + \"protocol\": \"openid-connect\", + \"protocolMapper\": \"oidc-audience-mapper\", + \"consentRequired\": false, + \"config\": { + \"included.client.audience\": \"acapy-resource-server\", + \"id.token.claim\": \"false\", + \"access.token.claim\": \"true\" + } + }, + { + \"name\": \"wallet-id\", + \"protocol\": \"openid-connect\", + \"protocolMapper\": \"oidc-hardcoded-claim-mapper\", + \"consentRequired\": false, + \"config\": { + \"claim.name\": \"wallet_id\", + \"claim.value\": \"${WALLET_ID}\", + \"jsonType.label\": \"String\", + \"id.token.claim\": \"false\", + \"access.token.claim\": \"true\", + \"userinfo.token.claim\": \"false\" + } + } + ] + }" > /dev/null + READONLY_CLIENT_UUID=$(curl -sf \ + "${REALM_URL}/clients?clientId=${READONLY_CLIENT_ID}" \ + -H "Authorization: Bearer ${KC_ADMIN_TOKEN}" \ + | jq -r '.[0].id // empty') + echo " Client created (UUID: ${READONLY_CLIENT_UUID})." +else + echo " Client exists (UUID: ${READONLY_CLIENT_UUID}), updating wallet-id mapper..." + RO_MAPPER_ID=$(curl -sf \ + "${REALM_URL}/clients/${READONLY_CLIENT_UUID}/protocol-mappers/models" \ + -H "Authorization: Bearer ${KC_ADMIN_TOKEN}" \ + | jq -r '.[] | select(.name == "wallet-id") | .id // empty') + if [[ -n "${RO_MAPPER_ID}" ]]; then + curl -sf -X DELETE \ + "${REALM_URL}/clients/${READONLY_CLIENT_UUID}/protocol-mappers/models/${RO_MAPPER_ID}" \ + -H "Authorization: Bearer ${KC_ADMIN_TOKEN}" + fi + curl -sf -X POST \ + "${REALM_URL}/clients/${READONLY_CLIENT_UUID}/protocol-mappers/models" \ + -H "Authorization: Bearer ${KC_ADMIN_TOKEN}" \ + -H "Content-Type: application/json" \ + -d "{ + \"name\": \"wallet-id\", + \"protocol\": \"openid-connect\", + \"protocolMapper\": \"oidc-hardcoded-claim-mapper\", + \"consentRequired\": false, + \"config\": { + \"claim.name\": \"wallet_id\", + \"claim.value\": \"${WALLET_ID}\", + \"jsonType.label\": \"String\", + \"id.token.claim\": \"false\", + \"access.token.claim\": \"true\", + \"userinfo.token.claim\": \"false\" + } + }" > /dev/null + echo " wallet-id mapper updated." +fi + +# ── assign acapy:wallet:create scope to tenant demo client ─────────────────── + +echo "==> Assigning acapy:wallet:create scope to client '${TENANT_CLIENT_ID}'..." + +# Look up the UUID of the acapy:wallet:create client scope +WALLET_CREATE_SCOPE_UUID=$(curl -sf \ + "${REALM_URL}/client-scopes" \ + -H "Authorization: Bearer ${KC_ADMIN_TOKEN}" \ + | jq -r '.[] | select(.name == "acapy:wallet:create") | .id // empty') + +if [[ -z "${WALLET_CREATE_SCOPE_UUID}" ]]; then + echo "ERROR: 'acapy:wallet:create' client scope not found in realm '${KEYCLOAK_REALM}'." >&2 + echo " Ensure the realm was imported from keycloak/realm-export.json." >&2 + exit 1 +fi +echo " acapy:wallet:create scope UUID: ${WALLET_CREATE_SCOPE_UUID}" + +# Assign as a default scope on the tenant demo client (idempotent — 409 is fine) +ASSIGN_STATUS=$(curl -s -o /dev/null -w "%{http_code}" -X PUT \ + "${REALM_URL}/clients/${KC_CLIENT_UUID}/default-client-scopes/${WALLET_CREATE_SCOPE_UUID}" \ + -H "Authorization: Bearer ${KC_ADMIN_TOKEN}") +if [[ "${ASSIGN_STATUS}" == "204" || "${ASSIGN_STATUS}" == "409" ]]; then + echo " acapy:wallet:create scope assigned (HTTP ${ASSIGN_STATUS})." +else + echo "ERROR: Failed to assign acapy:wallet:create scope (HTTP ${ASSIGN_STATUS})." >&2 + exit 1 +fi + +# ── provision limited tenant Keycloak client ───────────────────────────────── + +echo "==> Provisioning limited tenant client '${LIMITED_CLIENT_ID}'..." + +LIMITED_CLIENT_UUID=$(curl -sf \ + "${REALM_URL}/clients?clientId=${LIMITED_CLIENT_ID}" \ + -H "Authorization: Bearer ${KC_ADMIN_TOKEN}" \ + | jq -r '.[0].id // empty') + +if [[ -z "${LIMITED_CLIENT_UUID}" ]]; then + echo " Creating client..." + curl -sf -X POST "${REALM_URL}/clients" \ + -H "Authorization: Bearer ${KC_ADMIN_TOKEN}" \ + -H "Content-Type: application/json" \ + -d "{ + \"clientId\": \"${LIMITED_CLIENT_ID}\", + \"enabled\": true, + \"publicClient\": false, + \"serviceAccountsEnabled\": true, + \"standardFlowEnabled\": false, + \"clientAuthenticatorType\": \"client-secret\", + \"secret\": \"${LIMITED_CLIENT_SECRET}\", + \"defaultClientScopes\": [\"acapy:tenant\"], + \"optionalClientScopes\": [], + \"protocolMappers\": [ + { + \"name\": \"audience-acapy\", + \"protocol\": \"openid-connect\", + \"protocolMapper\": \"oidc-audience-mapper\", + \"consentRequired\": false, + \"config\": { + \"included.client.audience\": \"acapy-resource-server\", + \"id.token.claim\": \"false\", + \"access.token.claim\": \"true\" + } + }, + { + \"name\": \"wallet-id\", + \"protocol\": \"openid-connect\", + \"protocolMapper\": \"oidc-hardcoded-claim-mapper\", + \"consentRequired\": false, + \"config\": { + \"claim.name\": \"wallet_id\", + \"claim.value\": \"${WALLET_ID}\", + \"jsonType.label\": \"String\", + \"id.token.claim\": \"false\", + \"access.token.claim\": \"true\", + \"userinfo.token.claim\": \"false\" + } + } + ] + }" > /dev/null + LIMITED_CLIENT_UUID=$(curl -sf \ + "${REALM_URL}/clients?clientId=${LIMITED_CLIENT_ID}" \ + -H "Authorization: Bearer ${KC_ADMIN_TOKEN}" \ + | jq -r '.[0].id // empty') + echo " Client created (UUID: ${LIMITED_CLIENT_UUID})." +else + echo " Client exists (UUID: ${LIMITED_CLIENT_UUID}), updating wallet-id mapper..." + LIM_MAPPER_ID=$(curl -sf \ + "${REALM_URL}/clients/${LIMITED_CLIENT_UUID}/protocol-mappers/models" \ + -H "Authorization: Bearer ${KC_ADMIN_TOKEN}" \ + | jq -r '.[] | select(.name == "wallet-id") | .id // empty') + if [[ -n "${LIM_MAPPER_ID}" ]]; then + curl -sf -X DELETE \ + "${REALM_URL}/clients/${LIMITED_CLIENT_UUID}/protocol-mappers/models/${LIM_MAPPER_ID}" \ + -H "Authorization: Bearer ${KC_ADMIN_TOKEN}" + fi + curl -sf -X POST \ + "${REALM_URL}/clients/${LIMITED_CLIENT_UUID}/protocol-mappers/models" \ + -H "Authorization: Bearer ${KC_ADMIN_TOKEN}" \ + -H "Content-Type: application/json" \ + -d "{ + \"name\": \"wallet-id\", + \"protocol\": \"openid-connect\", + \"protocolMapper\": \"oidc-hardcoded-claim-mapper\", + \"consentRequired\": false, + \"config\": { + \"claim.name\": \"wallet_id\", + \"claim.value\": \"${WALLET_ID}\", + \"jsonType.label\": \"String\", + \"id.token.claim\": \"false\", + \"access.token.claim\": \"true\", + \"userinfo.token.claim\": \"false\" + } + }" > /dev/null + echo " wallet-id mapper updated." +fi + +# ── summary ─────────────────────────────────────────────────────────────────── + +TOKEN_ENDPOINT="${KEYCLOAK_URL}/realms/${KEYCLOAK_REALM}/protocol/openid-connect/token" + +echo "" +echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" +echo " Setup complete" +echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" +echo "" +echo " Wallet ID : ${WALLET_ID}" +echo " Tenant client : ${TENANT_CLIENT_ID} (acapy:tenant + acapy:wallet:create)" +echo " Limited client : ${LIMITED_CLIENT_ID} (acapy:tenant only — no wallet:create)" +echo " Readonly client : ${READONLY_CLIENT_ID} (acapy:tenant:read only)" +echo " Admin API : ${ACAPY_URL}/api/doc" +echo "" +echo " Get an admin token (acapy:admin scope):" +echo " curl -s -X POST '${TOKEN_ENDPOINT}' \\" +echo " -d 'grant_type=client_credentials' \\" +echo " -d 'client_id=${CONTROLLER_CLIENT_ID}' \\" +echo " -d 'client_secret=${CONTROLLER_CLIENT_SECRET}' \\" +echo " | jq -r .access_token" +echo "" +echo " Get a tenant token (acapy:tenant scope + wallet_id=${WALLET_ID}):" +echo " curl -s -X POST '${TOKEN_ENDPOINT}' \\" +echo " -d 'grant_type=client_credentials' \\" +echo " -d 'client_id=${TENANT_CLIENT_ID}' \\" +echo " -d 'client_secret=${TENANT_CLIENT_SECRET:-tenant-secret}' \\" +echo " | jq -r .access_token" +echo "" +echo " Call ACA-Py with the token:" +echo " TOKEN=\$(curl -s ... | jq -r .access_token)" +echo " curl -H \"Authorization: Bearer \$TOKEN\" ${ACAPY_URL}/connections" +echo "" diff --git a/demo/demo-authserver/scripts/start-acapy.sh b/demo/demo-authserver/scripts/start-acapy.sh new file mode 100755 index 0000000000..ab16b3f7c7 --- /dev/null +++ b/demo/demo-authserver/scripts/start-acapy.sh @@ -0,0 +1,26 @@ +#!/bin/bash +set -e + +cd /usr/src/app + +poetry run aca-py start \ + --auto-provision \ + --inbound-transport http 0.0.0.0 8001 \ + --outbound-transport http \ + --endpoint http://acapy:8001 \ + --admin 0.0.0.0 8031 \ + --oauth-jwks-uri "${KEYCLOAK_REALM_URL}/protocol/openid-connect/certs" \ + --oauth-issuer "${ACAPY_OAUTH_ISSUER}" \ + --oauth-audience "${ACAPY_OAUTH_AUDIENCE}" \ + --multitenant \ + --multitenant-admin \ + --jwt-secret "${ACAPY_JWT_SECRET}" \ + --wallet-type askar \ + --wallet-name demo-base-wallet \ + --wallet-key "${ACAPY_WALLET_KEY}" \ + --wallet-storage-type postgres_storage \ + --wallet-storage-config "{\"url\":\"${POSTGRES_HOST}:${POSTGRES_PORT}\",\"max_connections\":5}" \ + --wallet-storage-creds "{\"account\":\"${POSTGRES_USER}\",\"password\":\"${POSTGRES_PASSWORD}\",\"admin_account\":\"${POSTGRES_USER}\",\"admin_password\":\"${POSTGRES_PASSWORD}\"}" \ + --no-ledger \ + --label "oauth-demo-agent" \ + --log-level "${ACAPY_LOG_LEVEL}" diff --git a/demo/demo-authserver/scripts/test-oauth-scopes.sh b/demo/demo-authserver/scripts/test-oauth-scopes.sh new file mode 100755 index 0000000000..e041b53f6a --- /dev/null +++ b/demo/demo-authserver/scripts/test-oauth-scopes.sh @@ -0,0 +1,373 @@ +#!/bin/bash +# Tests OAuth2 scope enforcement against the demo ACA-Py instance. +# Runs positive and negative test cases across all configured clients +# and writes a markdown report. +# +# Prerequisites: all services healthy, ./scripts/setup-tenant.sh already run. +# +# Usage: +# ./scripts/test-oauth-scopes.sh +# +# Requires: curl, jq +set -euo pipefail + +# ── config ──────────────────────────────────────────────────────────────────── + +if [[ -f "$(dirname "$0")/../.env" ]]; then + set -o allexport + source "$(dirname "$0")/../.env" + set +o allexport +fi + +KEYCLOAK_URL=${KEYCLOAK_URL:-http://localhost:8080} +KEYCLOAK_REALM=${KEYCLOAK_REALM:-acapy} +ACAPY_URL=${ACAPY_URL:-http://localhost:8031} +CONTROLLER_CLIENT_ID=${CONTROLLER_CLIENT_ID:-acapy-controller} +CONTROLLER_CLIENT_SECRET=${CONTROLLER_CLIENT_SECRET:-controller-secret} +TENANT_CLIENT_ID=${TENANT_CLIENT_ID:-acapy-tenant-demo} +TENANT_CLIENT_SECRET=${TENANT_CLIENT_SECRET:-tenant-secret} +READONLY_CLIENT_ID=${READONLY_CLIENT_ID:-acapy-tenant-readonly} +READONLY_CLIENT_SECRET=${READONLY_CLIENT_SECRET:-readonly-secret} +LIMITED_CLIENT_ID=${LIMITED_CLIENT_ID:-acapy-tenant-limited} +LIMITED_CLIENT_SECRET=${LIMITED_CLIENT_SECRET:-limited-secret} + +TOKEN_ENDPOINT="${KEYCLOAK_URL}/realms/${KEYCLOAK_REALM}/protocol/openid-connect/token" +REPORT_FILE="$(dirname "$0")/../oauth-scope-test-report.md" + +# ── colours ─────────────────────────────────────────────────────────────────── + +GREEN='\033[0;32m' +RED='\033[0;31m' +YELLOW='\033[1;33m' +BOLD='\033[1m' +NC='\033[0m' + +# ── state ───────────────────────────────────────────────────────────────────── + +PASS=0 +FAIL=0 +REPORT_ROWS=() + +# ── helpers ─────────────────────────────────────────────────────────────────── + +wait_for() { + local label="$1" url="$2" + echo -n "==> Waiting for ${label}..." + until curl -sf "${url}" > /dev/null 2>&1; do printf "."; sleep 3; done + echo " ready." +} + +# Returns HTTP status code for a request. +# Usage: http_get [token] +# http_post [token] [json_body] +http_get() { + local url="$1" token="${2:-}" + if [[ -n "$token" ]]; then + curl -s -o /dev/null -w "%{http_code}" -H "Authorization: Bearer $token" "$url" + else + curl -s -o /dev/null -w "%{http_code}" "$url" + fi +} + +http_post() { + local url="$1" token="${2:-}" body="${3:-}" + [[ -z "$body" ]] && body='{}' + if [[ -n "$token" ]]; then + curl -s -o /dev/null -w "%{http_code}" -X POST \ + -H "Authorization: Bearer $token" \ + -H "Content-Type: application/json" \ + -d "$body" "$url" + else + curl -s -o /dev/null -w "%{http_code}" -X POST \ + -H "Content-Type: application/json" \ + -d "$body" "$url" + fi +} + +http_post_form() { + local url="$1" token="${2:-}" + if [[ -n "$token" ]]; then + curl -s -o /dev/null -w "%{http_code}" -X POST \ + -H "Authorization: Bearer $token" \ + -H "Content-Type: application/x-www-form-urlencoded" \ + "$url" + else + curl -s -o /dev/null -w "%{http_code}" -X POST \ + -H "Content-Type: application/x-www-form-urlencoded" \ + "$url" + fi +} + +# run_test +run_test() { + local desc="$1" expected="$2" actual="$3" group="$4" + local icon result + + if [[ "${actual}" == "${expected}" ]]; then + icon="${GREEN}PASS${NC}" + result="PASS" + PASS=$((PASS + 1)) + else + icon="${RED}FAIL${NC}" + result="FAIL ← got ${actual}" + FAIL=$((FAIL + 1)) + fi + + printf " [${icon}] %-60s expected=%s got=%s\n" "${desc}" "${expected}" "${actual}" + REPORT_ROWS+=("| ${group} | ${desc} | ${expected} | ${actual} | ${result} |") +} + +# ── wait for services ───────────────────────────────────────────────────────── + +wait_for "Keycloak" "${KEYCLOAK_URL}/health/ready" +wait_for "ACA-Py" "${ACAPY_URL}/status/ready" + +# ── obtain tokens ───────────────────────────────────────────────────────────── + +echo "" +echo "==> Obtaining tokens..." + +ADMIN_TOKEN=$(curl -sf -X POST "${TOKEN_ENDPOINT}" \ + -H "Content-Type: application/x-www-form-urlencoded" \ + -d "grant_type=client_credentials" \ + -d "client_id=${CONTROLLER_CLIENT_ID}" \ + -d "client_secret=${CONTROLLER_CLIENT_SECRET}" \ + | jq -r '.access_token') +echo " admin token: obtained (${CONTROLLER_CLIENT_ID})" + +TENANT_TOKEN=$(curl -sf -X POST "${TOKEN_ENDPOINT}" \ + -H "Content-Type: application/x-www-form-urlencoded" \ + -d "grant_type=client_credentials" \ + -d "client_id=${TENANT_CLIENT_ID}" \ + -d "client_secret=${TENANT_CLIENT_SECRET}" \ + | jq -r '.access_token') +echo " tenant token: obtained (${TENANT_CLIENT_ID})" + +# Verify wallet_id is set in the tenant token +WALLET_ID=$(echo "${TENANT_TOKEN}" \ + | cut -d. -f2 \ + | tr '_-' '/+' \ + | awk '{l=length($0)%4; if(l==2) print $0"=="; else if(l==3) print $0"="; else print $0}' \ + | base64 -d 2>/dev/null \ + | jq -r '.wallet_id // empty') + +if [[ -z "${WALLET_ID}" || "${WALLET_ID}" == "PLACEHOLDER_WALLET_ID" ]]; then + echo "" + echo "ERROR: tenant token does not contain a valid wallet_id claim." >&2 + echo " Run ./scripts/setup-tenant.sh first." >&2 + exit 1 +fi +echo " wallet_id: ${WALLET_ID}" + +READONLY_TOKEN=$(curl -sf -X POST "${TOKEN_ENDPOINT}" \ + -H "Content-Type: application/x-www-form-urlencoded" \ + -d "grant_type=client_credentials" \ + -d "client_id=${READONLY_CLIENT_ID}" \ + -d "client_secret=${READONLY_CLIENT_SECRET}" \ + | jq -r '.access_token') +echo " readonly token: obtained (${READONLY_CLIENT_ID})" + +LIMITED_TOKEN=$(curl -sf -X POST "${TOKEN_ENDPOINT}" \ + -H "Content-Type: application/x-www-form-urlencoded" \ + -d "grant_type=client_credentials" \ + -d "client_id=${LIMITED_CLIENT_ID}" \ + -d "client_secret=${LIMITED_CLIENT_SECRET}" \ + | jq -r '.access_token') +echo " limited token: obtained (${LIMITED_CLIENT_ID})" + +# Create a temporary wallet for admin write tests, then remove it +TEST_WALLET_NAME="oauth-scope-test-$$" +echo " Creating temporary test wallet '${TEST_WALLET_NAME}'..." +CREATE_RESP=$(curl -s -X POST "${ACAPY_URL}/multitenancy/wallet" \ + -H "Authorization: Bearer ${ADMIN_TOKEN}" \ + -H "Content-Type: application/json" \ + -d "{\"label\":\"${TEST_WALLET_NAME}\",\"wallet_name\":\"${TEST_WALLET_NAME}\",\"wallet_type\":\"askar\",\"wallet_key\":\"${TEST_WALLET_NAME}-key\",\"key_management_mode\":\"managed\"}") +TEST_WALLET_ID=$(echo "${CREATE_RESP}" | jq -r '.wallet_id // empty') +if [[ -z "${TEST_WALLET_ID}" ]]; then + echo "WARNING: Could not create temporary test wallet. Skipping wallet-create/delete tests." +fi + +# ── run tests ───────────────────────────────────────────────────────────────── + +echo "" +printf "${BOLD}%-70s %-10s %-10s\n${NC}" "Test" "Expected" "Actual" +printf '%0.s─' {1..95}; echo "" + +# ── Group 1: Public endpoints (no token required) ───────────────────────────── + +echo "" +printf "${YELLOW}Group 1: Public endpoints (no authentication)${NC}\n" + +run_test "GET /status/ready — no token" "200" "$(http_get "${ACAPY_URL}/status/ready")" "Public" +run_test "GET /status/live — no token" "200" "$(http_get "${ACAPY_URL}/status/live")" "Public" + +# ── Group 2: Unauthenticated requests to protected endpoints ────────────────── + +echo "" +printf "${YELLOW}Group 2: Protected endpoints — no token (expect 401)${NC}\n" + +run_test "GET /multitenancy/wallets — no token" "401" "$(http_get "${ACAPY_URL}/multitenancy/wallets")" "No token" +run_test "GET /connections — no token" "401" "$(http_get "${ACAPY_URL}/connections")" "No token" +run_test "GET /credentials — no token" "401" "$(http_get "${ACAPY_URL}/credentials")" "No token" + +# ── Group 3: Invalid token ──────────────────────────────────────────────────── + +echo "" +printf "${YELLOW}Group 3: Invalid bearer token (expect 401)${NC}\n" + +run_test "GET /multitenancy/wallets — invalid token" "401" "$(http_get "${ACAPY_URL}/multitenancy/wallets" "not-a-valid-token")" "Invalid token" +run_test "GET /connections — invalid token" "401" "$(http_get "${ACAPY_URL}/connections" "not-a-valid-token")" "Invalid token" + +# ── Group 4: Admin token — GET (acapy:admin scope) ─────────────────────────── + +echo "" +printf "${YELLOW}Group 4: Admin token — GET routes (acapy:admin scope)${NC}\n" + +run_test "GET /status/ready — admin token" "200" "$(http_get "${ACAPY_URL}/status/ready" "${ADMIN_TOKEN}")" "Admin GET" +run_test "GET /status/config — admin token" "200" "$(http_get "${ACAPY_URL}/status/config" "${ADMIN_TOKEN}")" "Admin GET" +run_test "GET /multitenancy/wallets — admin token" "200" "$(http_get "${ACAPY_URL}/multitenancy/wallets" "${ADMIN_TOKEN}")" "Admin GET" +run_test "GET /connections — admin token" "200" "$(http_get "${ACAPY_URL}/connections" "${ADMIN_TOKEN}")" "Admin GET" +run_test "GET /credentials — admin token" "200" "$(http_get "${ACAPY_URL}/credentials" "${ADMIN_TOKEN}")" "Admin GET" + +if [[ -n "${TEST_WALLET_ID}" ]]; then + run_test "GET /multitenancy/wallet/{id} — admin token" "200" \ + "$(http_get "${ACAPY_URL}/multitenancy/wallet/${TEST_WALLET_ID}" "${ADMIN_TOKEN}")" \ + "Admin GET" +fi + +# ── Group 5: Admin token — POST (acapy:admin scope) ────────────────────────── + +echo "" +printf "${YELLOW}Group 5: Admin token — POST routes (acapy:admin scope)${NC}\n" + +run_test "POST /wallet/did/create — admin token" "200" \ + "$(http_post "${ACAPY_URL}/wallet/did/create" "${ADMIN_TOKEN}" '{"method":"key","options":{"key_type":"ed25519"}}')" \ + "Admin POST" + +if [[ -n "${TEST_WALLET_ID}" ]]; then + run_test "POST /multitenancy/wallet/{id}/remove — admin token" "200" \ + "$(http_post_form "${ACAPY_URL}/multitenancy/wallet/${TEST_WALLET_ID}/remove" "${ADMIN_TOKEN}")" \ + "Admin POST" +fi + +# ── Group 6: Tenant token — GET (acapy:tenant scope) ───────────────────────── + +echo "" +printf "${YELLOW}Group 6: Tenant token — GET routes (acapy:tenant scope)${NC}\n" + +run_test "GET /connections — tenant token" "200" "$(http_get "${ACAPY_URL}/connections" "${TENANT_TOKEN}")" "Tenant GET" +run_test "GET /credentials — tenant token" "200" "$(http_get "${ACAPY_URL}/credentials" "${TENANT_TOKEN}")" "Tenant GET" +run_test "GET /wallet/did — tenant token" "200" "$(http_get "${ACAPY_URL}/wallet/did" "${TENANT_TOKEN}")" "Tenant GET" + +# ── Group 7: Tenant token — POST (acapy:tenant scope) ──────────────────────── + +echo "" +printf "${YELLOW}Group 7: Tenant token — POST routes (acapy:tenant scope)${NC}\n" + +run_test "POST /wallet/did/create — tenant token" "200" \ + "$(http_post "${ACAPY_URL}/wallet/did/create" "${TENANT_TOKEN}" '{"method":"key","options":{"key_type":"ed25519"}}')" \ + "Tenant POST" + +# ── Group 8: Tenant token — admin endpoints (expect 403) ───────────────────── + +echo "" +printf "${YELLOW}Group 8: Tenant token — admin endpoints (expect 403 Forbidden)${NC}\n" + +run_test "GET /multitenancy/wallets — tenant token" "403" "$(http_get "${ACAPY_URL}/multitenancy/wallets" "${TENANT_TOKEN}")" "Tenant→Admin" +run_test "GET /multitenancy/wallet/{id} — tenant token" "403" \ + "$(http_get "${ACAPY_URL}/multitenancy/wallet/${WALLET_ID}" "${TENANT_TOKEN}")" \ + "Tenant→Admin" +run_test "GET /status/config — tenant token" "403" "$(http_get "${ACAPY_URL}/status/config" "${TENANT_TOKEN}")" "Tenant→Admin" + +# ── Group 9: Read-only tenant token (acapy:tenant:read) ────────────────────── + +echo "" +printf "${YELLOW}Group 9: Read-only token — acapy:tenant:read scope${NC}\n" + +run_test "GET /connections — readonly token" "200" "$(http_get "${ACAPY_URL}/connections" "${READONLY_TOKEN}")" "Read-only" +run_test "GET /credentials — readonly token" "200" "$(http_get "${ACAPY_URL}/credentials" "${READONLY_TOKEN}")" "Read-only" +run_test "GET /wallet/did — readonly token" "200" "$(http_get "${ACAPY_URL}/wallet/did" "${READONLY_TOKEN}")" "Read-only" +run_test "POST /wallet/did/create — readonly token" "403" \ + "$(http_post "${ACAPY_URL}/wallet/did/create" "${READONLY_TOKEN}" '{"method":"key","options":{"key_type":"ed25519"}}')" \ + "Read-only" +run_test "GET /multitenancy/wallets — readonly token" "403" "$(http_get "${ACAPY_URL}/multitenancy/wallets" "${READONLY_TOKEN}")" "Read-only" + +# ── Group 10: acapy:wallet:create scope enforcement ────────────────────────── +# limited token has acapy:tenant but NOT acapy:wallet:create. +# Demonstrates require_scope blocking POST /wallet/did/create even though +# tenant_authentication passes (acapy:tenant is present). + +echo "" +printf "${YELLOW}Group 10: acapy:wallet:create scope enforcement${NC}\n" + +run_test "GET /connections — limited token (acapy:tenant, no wallet:create)" "200" \ + "$(http_get "${ACAPY_URL}/connections" "${LIMITED_TOKEN}")" \ + "wallet:create scope" + +run_test "POST /wallet/did/create — limited token (missing acapy:wallet:create)" "403" \ + "$(http_post "${ACAPY_URL}/wallet/did/create" "${LIMITED_TOKEN}" '{"method":"key","options":{"key_type":"ed25519"}}')" \ + "wallet:create scope" + +run_test "POST /wallet/did/create — tenant token (has acapy:wallet:create)" "200" \ + "$(http_post "${ACAPY_URL}/wallet/did/create" "${TENANT_TOKEN}" '{"method":"key","options":{"key_type":"ed25519"}}')" \ + "wallet:create scope" + +run_test "POST /wallet/did/create — admin token (acapy:admin satisfies require_scope)" "200" \ + "$(http_post "${ACAPY_URL}/wallet/did/create" "${ADMIN_TOKEN}" '{"method":"key","options":{"key_type":"ed25519"}}')" \ + "wallet:create scope" + +# ── summary ─────────────────────────────────────────────────────────────────── + +TOTAL=$((PASS + FAIL)) +echo "" +printf '%0.s─' {1..95}; echo "" +printf "${BOLD}Results: ${GREEN}${PASS} passed${NC}${BOLD}, ${RED}${FAIL} failed${NC}${BOLD}, ${TOTAL} total${NC}\n" + +# ── write markdown report ───────────────────────────────────────────────────── + +TIMESTAMP=$(date '+%Y-%m-%d %H:%M:%S') + +{ + echo "# OAuth Scope Test Report" + echo "" + echo "**Date:** ${TIMESTAMP}" + echo "**ACA-Py:** ${ACAPY_URL}" + echo "**Keycloak realm:** ${KEYCLOAK_URL}/realms/${KEYCLOAK_REALM}" + echo "**Wallet ID under test:** ${WALLET_ID}" + echo "" + echo "## Summary" + echo "" + echo "| Result | Count |" + echo "|--------|-------|" + echo "| Passed | ${PASS} |" + echo "| Failed | ${FAIL} |" + echo "| Total | ${TOTAL} |" + echo "" + echo "## Test Cases" + echo "" + echo "| Group | Test | Expected | Actual | Result |" + echo "|-------|------|----------|--------|--------|" + for row in "${REPORT_ROWS[@]}"; do + echo "${row}" + done + echo "" + echo "## Scope Matrix" + echo "" + echo "| Endpoint | No token | Invalid token | acapy:admin | acapy:tenant + acapy:wallet:create | acapy:tenant (no wallet:create) | acapy:tenant:read |" + echo "|----------|----------|---------------|-------------|-------------------------------------|----------------------------------|-------------------|" + echo "| \`GET /status/ready\` | 200 | 200 | 200 | 200 | 200 | 200 |" + echo "| \`GET /status/config\` | 401 | 401 | 200 | 403 | 403 | 403 |" + echo "| \`GET /multitenancy/wallets\` | 401 | 401 | 200 | 403 | 403 | 403 |" + echo "| \`GET /multitenancy/wallet/{id}\` | 401 | 401 | 200 | 403 | 403 | 403 |" + echo "| \`GET /connections\` | 401 | 401 | 200 | 200 | 200 | 200 |" + echo "| \`GET /credentials\` | 401 | 401 | 200 | 200 | 200 | 200 |" + echo "| \`GET /wallet/did\` | 401 | 401 | 200 | 200 | 200 | 200 |" + echo "| \`POST /wallet/did/create\` | 401 | 401 | 200 | 200 | **403** | 403 |" + echo "| \`POST /multitenancy/wallet/{id}/remove\` | 401 | 401 | 200 | 403 | 403 | 403 |" +} > "${REPORT_FILE}" + +echo "" +echo "==> Report written to: ${REPORT_FILE}" +echo "" + +[[ ${FAIL} -gt 0 ]] && exit 1 || exit 0 diff --git a/docs/features/OAuthResourceServer.md b/docs/features/OAuthResourceServer.md new file mode 100644 index 0000000000..9064d4868e --- /dev/null +++ b/docs/features/OAuthResourceServer.md @@ -0,0 +1,419 @@ +# OAuth2 Resource Server Authentication + +ACA-Py can be configured to act as an **OAuth2 Resource Server**, delegating all authentication and authorisation to an external **Authorization Server (AS)** such as Keycloak, Auth0, or any other OIDC-compliant provider. This replaces the built-in API key and the ACA-Py-issued multitenant JWT with access tokens that the AS issues directly to callers. + +## Table of Contents + +- [Overview](#overview) +- [Request Flow](#request-flow) +- [Scopes](#scopes) +- [Configuration](#configuration) + - [Startup Parameters](#startup-parameters) + - [Environment Variables](#environment-variables) + - [Token Validation Modes](#token-validation-modes) +- [Multitenancy and the `wallet_id` Claim](#multitenancy-and-the-wallet_id-claim) +- [Request Claim Storage and Compatibility](#request-claim-storage-and-compatibility) +- [Annotating Routes with Scopes](#annotating-routes-with-scopes) +- [Authorization Server Setup Guidance](#authorization-server-setup-guidance) + - [Keycloak Example](#keycloak-example) + - [Keycloak Hostname and Issuer](#keycloak-hostname-and-issuer) +- [Demo: Docker Compose with Keycloak](#demo-docker-compose-with-keycloak) + - [Scripts](#scripts) +- [Migrating from API Key / ACA-Py JWT](#migrating-from-api-key--aca-py-jwt) +- [Limitations](#limitations) + +--- + +## Overview + +In the traditional ACA-Py security model there are two mechanisms for protecting the Admin API: + +| Mode | Parameter | Token issuer | +|---|---|---| +| API key | `--admin-api-key` | n/a — static shared secret | +| Multitenant JWT | `multitenant.jwt_secret` | ACA-Py itself | + +Both modes are binary — a caller is either fully authorised or not. There is no concept of scopes or roles. + +The OAuth2 Resource Server mode introduces a third option: + +| Mode | Parameter | Token issuer | +|---|---|---| +| OAuth2 RS | `--oauth-jwks-uri` / `--oauth-introspection-endpoint` | External AS | + +When OAuth2 RS mode is active: + +- ACA-Py **never issues tokens** — the `create_auth_token` multitenant endpoint is not used. +- ACA-Py **never stores or validates a shared secret** for bearer tokens. +- The AS is the sole authority on who may call which endpoints. +- Individual routes declare the **minimum scope** required to access them. + +--- + +## Request Flow + +```mermaid +sequenceDiagram + participant C as Client + participant AS as Authorization Server + participant RS as ACA-Py (Resource Server) + + C->>AS: (1) Authenticate
(OIDC / PKCE / client credentials / etc.) + AS-->>C: (2) Access token + + C->>RS: (3) API request
Authorization: Bearer [token] + + RS->>AS: (4) Fetch JWKS (cached) + AS-->>RS: JWKS signing keys + Note over RS: (5) Validate signature,
expiry, iss, aud + + opt Opaque token introspection fallback + RS->>AS: (6) POST introspect token + AS-->>RS: { active, scope, sub, wallet_id, … } + end + + Note over RS: (7) Check token scopes
against route requirement + + RS-->>C: (8) API response +``` + + JWT validation occurs on every request; introspection is only used for opaque tokens or when JWT decoding falls back to introspection. JWKS keys are cached in memory by `PyJWKClient` so the round-trip to the AS only occurs when the key set changes. + +--- + +## Scopes + +ACA-Py uses the `scope` claim from the access token. Scopes are space-separated strings following the convention `acapy:[:]`. + +| Scope | Intended use | +|---|---| +| `acapy:admin` | Full administrative access — wallet management, server config, ledger operations | +| `acapy:tenant` | Tenant-level access — credentials, connections, presentations for a specific sub-wallet | +| `acapy:tenant:read` | Read-only tenant access | +| `acapy:wallet:create` | Permission to create new sub-wallets only | + +### Scope-to-decorator mapping + +Route handlers are annotated with one of two authentication decorators. In OAuth mode each decorator enforces a minimum scope: + +| Decorator | OAuth scope required | Legacy auth | +|---|---|---| +| `@admin_authentication` | `acapy:admin` | `x-api-key` or insecure mode | +| `@tenant_authentication` | `acapy:tenant` **or** `acapy:admin` | Bearer JWT or `x-api-key` | + +A request passes scope enforcement if it holds **at least one** of the required scopes. `acapy:admin` implies full access and satisfies both decorators. All `/multitenancy/*` routes use `@admin_authentication` and therefore require `acapy:admin`. + +--- + +## Configuration + +### Startup Parameters + +| Parameter | Description | +|---|---| +| `--oauth-enabled` | Enable OAuth2 RS mode explicitly. Implied by either of the two parameters below. | +| `--oauth-jwks-uri ` | JWKS endpoint of the AS. ACA-Py fetches and caches signing keys from here to validate JWT access tokens locally. | +| `--oauth-issuer ` | Expected value of the `iss` claim. Tokens with a different issuer are rejected. | +| `--oauth-audience ` | Expected value of the `aud` claim. **Required whenever `--oauth-jwks-uri` is set** — ACA-Py refuses to start without it, so JWT access tokens are always bound to this resource server. | +| `--oauth-introspection-endpoint ` | RFC 7662 introspection endpoint. Used as a fallback for opaque tokens, or as the sole validation method when `--oauth-jwks-uri` is not set. | +| `--oauth-introspection-client-id ` | Client ID for HTTP Basic Auth on the introspection endpoint. Required when the introspection endpoint is configured. | +| `--oauth-introspection-client-secret ` | Client secret for HTTP Basic Auth on the introspection endpoint. | +| `--oauth-http-timeout ` | Timeout for HTTP calls to the AS (JWKS key fetches and token introspection). Default 10. | + +When any of `--oauth-jwks-uri` or `--oauth-introspection-endpoint` is provided, **neither `--admin-api-key` nor `--admin-insecure-mode` is required**. + +Configuration is validated at startup and ACA-Py refuses to start when: + +- OAuth mode is enabled (via any of the three flags) but neither `--oauth-jwks-uri` nor `--oauth-introspection-endpoint` is set — there would be no way to validate tokens. +- `--oauth-introspection-endpoint` is set without `--oauth-introspection-client-id`. +- `--oauth-jwks-uri` is set without `--oauth-audience` — without an expected audience, any signature-valid token from the JWKS would be accepted regardless of its intended recipient (token reuse / confused-deputy on a shared Authorization Server). + +Example startup using JWKS validation: + +```bash +aca-py start \ + --admin 0.0.0.0 8031 \ + --oauth-jwks-uri https://auth.example.com/realms/acapy/protocol/openid-connect/certs \ + --oauth-issuer https://auth.example.com/realms/acapy \ + --oauth-audience acapy-resource-server \ + --multitenant \ + --multitenant-admin \ + ... +``` + +Example startup using introspection only (for opaque tokens): + +```bash +aca-py start \ + --admin 0.0.0.0 8031 \ + --oauth-introspection-endpoint https://auth.example.com/oauth/introspect \ + --oauth-introspection-client-id acapy-rs \ + --oauth-introspection-client-secret \ + ... +``` + +### Environment Variables + +Each parameter has a corresponding environment variable: + +| Parameter | Environment variable | +|---|---| +| `--oauth-jwks-uri` | `ACAPY_OAUTH_JWKS_URI` | +| `--oauth-issuer` | `ACAPY_OAUTH_ISSUER` | +| `--oauth-audience` | `ACAPY_OAUTH_AUDIENCE` | +| `--oauth-introspection-endpoint` | `ACAPY_OAUTH_INTROSPECTION_ENDPOINT` | +| `--oauth-introspection-client-id` | `ACAPY_OAUTH_INTROSPECTION_CLIENT_ID` | +| `--oauth-introspection-client-secret` | `ACAPY_OAUTH_INTROSPECTION_CLIENT_SECRET` | +| `--oauth-http-timeout` | `ACAPY_OAUTH_HTTP_TIMEOUT` | + +### Token Validation Modes + +**JWT via JWKS (recommended)** + +ACA-Py uses `PyJWKClient` (bundled with `pyjwt >= 2.x`) to fetch signing keys from the JWKS endpoint and validate tokens locally. Signature, expiry, issuer, and audience are all checked. Supported algorithms: RS256/384/512, ES256/384/512, PS256/384/512. + +JWKS key fetches happen on a cache miss (first request, or key rotation) and are subject to `--oauth-http-timeout`. The fetch runs in a worker thread so a slow AS does not block other requests. + +**Introspection fallback** + +If JWKS validation fails with a decode error (e.g. the token is opaque, not a JWT) and `--oauth-introspection-endpoint` is configured, ACA-Py falls back to RFC 7662 introspection. The introspection response must include `"active": true`; the `scope` field is used for scope enforcement. + +Introspection calls use a shared HTTP session with a timeout of `--oauth-http-timeout` seconds (default 10). If the AS cannot be reached (network error or timeout), ACA-Py responds with `503 Service Unavailable` rather than `401`, since the failure says nothing about the token's validity. + +**Combined mode** + +Providing both `--oauth-jwks-uri` and `--oauth-introspection-endpoint` gives you JWT-first validation with opaque token fallback. This covers deployments where the AS issues different token types to different clients. + +--- + +## Multitenancy and the `wallet_id` Claim + +In multitenant deployments, ACA-Py needs to know which sub-wallet a request should be routed to. With the built-in JWT this was encoded as `wallet_id` in the token payload. With external OAuth tokens, the AS must include a **custom claim** named `wallet_id` containing the ACA-Py sub-wallet UUID. + +When ACA-Py receives a request: + +1. The access token is validated (JWKS or introspection). +2. The `wallet_id` claim is read from the token. +3. The corresponding `WalletRecord` is loaded from storage. +4. The request is executed in that wallet's profile context. + +If `wallet_id` is absent from the token, the request is **rejected with `401 Unauthorized` unless the token carries the `acapy:admin` scope**. Admin-scoped tokens without a `wallet_id` run against the **base wallet**. This prevents a misconfigured AS (e.g. a tenant client missing its `wallet_id` mapper) from silently granting tenant tokens access to base-wallet data. + +### Provisioning flow + +1. An admin caller (holding `acapy:admin` scope) creates a sub-wallet via `POST /multitenancy/wallet`. A `wallet_key` must be provided even when `key_management_mode` is `managed` — ACA-Py stores and manages the key but Askar requires one at creation time. +2. ACA-Py returns the new `wallet_id` in the response. +3. The operator configures the AS to embed that `wallet_id` as a custom claim in tokens issued to the corresponding user or client. + +In Keycloak this is done with a **Protocol Mapper** of type *Hardcoded Claim*: +- Token Claim Name: `wallet_id` +- Claim Value: `` +- Add to access token: enabled + +--- + +## Request Claim Storage and Compatibility + +OAuth token-derived values (`scope`, `sub`, `wallet_id`) are attached to the +request in `AdminRequestContext.metadata`. + +In addition, ACA-Py mirrors these values into **request-scoped settings** for +compatibility with integrations that already read from `context.settings` / +`context.profile.settings`. + +Request-scoped settings keys: + +| Claim | Request-scoped setting key | +|---|---| +| `scope` | `auth.scopes` | +| `sub` | `auth.subject` | +| `wallet_id` | `auth.wallet_id` | + +### Why both metadata and settings? + +- `metadata` is the canonical location for per-request auth claims. +- Request-scoped settings provide a low-friction compatibility path for code + and plugins that already rely on settings access patterns. +- This avoids storing OAuth claims in global/shared settings while still + reducing break risk for existing extensions. + +### Recommended access pattern for core and plugins + +Use helper functions from `acapy_agent.admin.auth_context` instead of reading +`context.metadata` or settings directly. The helpers normalize values and +handle both storage paths safely. + +```python +from acapy_agent.admin.auth_context import ( + get_auth_scopes, + get_auth_subject, + get_auth_wallet_id, + has_auth_wallet_id, +) + +context = request["context"] +scopes = get_auth_scopes(context) +subject = get_auth_subject(context) +wallet_id = get_auth_wallet_id(context) +is_subwallet = has_auth_wallet_id(context) +``` + +--- + +## Annotating Routes with Scopes + +Use the `require_scope` decorator from `acapy_agent.admin.decorators.auth`. It must be stacked **inside** `tenant_authentication` (or `admin_authentication`) so that authentication is checked before scope enforcement. + +```python +from acapy_agent.admin.decorators.auth import require_scope, tenant_authentication + +@docs(tags=["credential"], summary="Issue a credential") +@tenant_authentication +@require_scope("acapy:tenant", "acapy:admin") +async def credential_issue(request: web.Request) -> web.Response: + ... +``` + +A request passes if its token contains **any one** of the listed scopes. To require a scope that only admins hold, list only `"acapy:admin"`. + +For route logic, prefer the auth-context helpers: + +```python +from acapy_agent.admin.auth_context import ( + get_auth_scopes, + get_auth_subject, + get_auth_wallet_id, +) + +context = request["context"] +scopes = get_auth_scopes(context) +subject = get_auth_subject(context) +wallet_id = get_auth_wallet_id(context) +``` + +--- + +## Authorization Server Setup Guidance + +### Keycloak Example + +1. **Create a Realm** (e.g. `acapy`). + +2. **Create a Client** representing ACA-Py as the resource server: + - Client ID: `acapy-resource-server` + - Access Type: `bearer-only` + - This client does not issue tokens — it is used only as the audience target. + +3. **Create Client Scopes** for each ACA-Py scope: + - `acapy:admin` + - `acapy:tenant` + - `acapy:tenant:read` + - `acapy:wallet:create` + - Set *Include in Token Scope* to enabled on each. + +4. **Create Clients for callers**: + + - **Admin / controller** (server-to-server): confidential client, `client_credentials` grant, assign `acapy:admin` as default scope. Add an *Audience* protocol mapper pointing to `acapy-resource-server`. + - **Tenant service account** (server-to-server): confidential client, `client_credentials` grant, assign `acapy:tenant` as default scope. Add an *Audience* mapper and a *Hardcoded Claim* mapper for `wallet_id`. + - **End-user client** (browser): public client, authorization code + PKCE, assign `acapy:tenant` as default scope. Add *Audience* and `wallet_id` mappers as above. + +5. **Configure ACA-Py**: + ``` + --oauth-jwks-uri https:///realms/acapy/protocol/openid-connect/certs + --oauth-issuer https:///realms/acapy + --oauth-audience acapy-resource-server + ``` + +### Keycloak Hostname and Issuer + +The `iss` claim in a Keycloak-issued token reflects the URL from which the token was requested. In containerised deployments, clients outside the container network hit Keycloak on a host-accessible URL (e.g. `http://localhost:8080`) while ACA-Py reaches Keycloak on an internal Docker network URL (e.g. `http://keycloak:8080`). These produce different `iss` values, causing `--oauth-issuer` validation to fail. + +The recommended fix is to set `KC_HOSTNAME` in Keycloak so that the `iss` claim always uses a single canonical URL regardless of which network interface handled the request: + +```yaml +# docker-compose.yml — Keycloak service +environment: + KC_HOSTNAME: localhost + KC_HOSTNAME_PORT: "8080" + KC_HOSTNAME_STRICT: "false" +``` + +Then set `--oauth-issuer` to match that canonical URL: + +```bash +--oauth-issuer http://localhost:8080/realms/acapy +``` + +ACA-Py's `--oauth-jwks-uri` can still use the internal Docker network hostname for the JWKS fetch — issuer validation is a string comparison against the `iss` claim and requires no network call. + +--- + +## Demo: Docker Compose with Keycloak + +A self-contained demo is provided in [`demo/demo-authserver/`](../../demo/demo-authserver/). It starts three services: + +| Service | Image | Purpose | +|---|---|---| +| `keycloak` | `quay.io/keycloak/keycloak:24` | Authorization Server, pre-loaded with the `acapy` realm | +| `wallet-db` | `postgres:16` | ACA-Py wallet storage | +| `acapy` | Built from repo | ACA-Py configured as an OAuth2 Resource Server | + +**Quick start:** + +```bash +cd demo/demo-authserver +podman compose up --build # or docker compose up --build + +# In a second terminal, once all services are healthy: +./scripts/setup-tenant.sh +``` + +### Scripts + +All scripts read common settings from a `.env` file if present and default sensibly otherwise. + +| Script | Description | +|---|---| +| `setup-tenant.sh` | Creates an ACA-Py sub-wallet using an admin token, then updates the Keycloak `wallet-id` claim on the `acapy-tenant-demo` client. Must be run before the tenant scripts. | +| `get-admin-token.sh` | Obtains an admin token via `client_credentials` grant for `acapy-controller` and prints the decoded claims and raw token. | +| `get-tenant-token.sh` | Obtains a tenant token via `client_credentials` grant for a confidential tenant client (defaults to `acapy-tenant-demo`). Prints decoded claims including `wallet_id`. | +| `get-user-token.sh` | Creates a demo user in Keycloak (if needed) and performs a full **authorization code + PKCE** flow. Prints a Keycloak login URL to open in the browser; a local callback server on port 9999 receives the code and exchanges it for a token. | + +**Example usage after `setup-tenant.sh`:** + +```bash +# Server-to-server admin token +./scripts/get-admin-token.sh + +# Server-to-server tenant token (confidential client) +./scripts/get-tenant-token.sh + +# Browser-based user token (public client, PKCE) +./scripts/get-user-token.sh + +# Call ACA-Py with a token +TOKEN=$(./scripts/get-admin-token.sh | grep -A1 "Access token:" | tail -1) +curl -s -H "Authorization: Bearer $TOKEN" http://localhost:8031/multitenancy/wallets | jq . +``` + +--- + +## Migrating from API Key / ACA-Py JWT + +| Before | After | +|---|---| +| `--admin-api-key ` | Remove; add `--oauth-jwks-uri` (and/or introspection params) | +| `--admin-insecure-mode` | Remove; configure OAuth | +| `--multitenant-jwt-secret ` | Still required by the multitenant subsystem but unused for token validation in OAuth mode | +| `POST /multitenancy/wallet/{id}/token` | Clients obtain tokens from the AS directly | +| `X-API-Key: ` request header | `Authorization: Bearer ` request header | +| `@admin_authentication` on admin routes | Unchanged decorator; now enforces `acapy:admin` scope in OAuth mode | +| `@tenant_authentication` on tenant routes | Unchanged decorator; now enforces `acapy:tenant` or `acapy:admin` scope in OAuth mode | + +--- + +## Limitations + +- **Unmanaged wallets are not supported in OAuth mode.** The ACA-Py-issued JWT could carry a `wallet_key` for wallets whose keys are not stored by ACA-Py. An OAuth access token from an external AS must not contain cryptographic wallet keys; use managed wallets (`key_management_mode: managed`) with OAuth. +- **WebSocket authentication** uses the same bearer token presented in the initial HTTP upgrade request. In-message `x-api-key` re-authentication is not available in OAuth mode. The admin event stream (`GET /ws` on the admin port) is scope- and wallet-aware: an `acapy:admin` token receives events for all wallets, while an `acapy:tenant` / `acapy:tenant:read` token receives only events for the `wallet_id` in its token (and, in multitenant mode, a token without a `wallet_id` is not permitted to receive events). This mirrors the tenant isolation enforced on the HTTP routes. Note this is the admin notification websocket, distinct from any DIDComm WebSocket inbound transport. +- **JWKS key rotation** is handled automatically by `PyJWKClient`'s built-in cache, which re-fetches the key set when a token references an unknown key ID (`kid`). No ACA-Py restart is required.