diff --git a/acapy_agent/admin/decorators/auth.py b/acapy_agent/admin/decorators/auth.py index 500850cbcf..a8ba531072 100644 --- a/acapy_agent/admin/decorators/auth.py +++ b/acapy_agent/admin/decorators/auth.py @@ -22,23 +22,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 context.metadata and "scopes" in context.metadata: + if "acapy:admin" in context.metadata["scopes"]: + 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 +68,29 @@ 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. + # acapy:tenant or acapy:admin both grant tenant-level access. + # acapy:tenant:read grants read-only access (safe HTTP methods only). + if context.metadata and "scopes" in context.metadata: + scopes = context.metadata["scopes"] + if scopes & {"acapy:tenant", "acapy:admin"}: + return await handler(request) + if "acapy:tenant:read" in scopes: + if request.method in ("GET", "HEAD"): + return await handler(request) + 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", + ) + authorization_header = request.headers.get("Authorization") header_admin_api_key = request.headers.get("x-api-key") valid_key = general_utils.const_compare( @@ -73,25 +106,61 @@ 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: set = (context.metadata or {}).get("scopes", set()) + 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 _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..74475b6bb8 --- /dev/null +++ b/acapy_agent/admin/oauth_validator.py @@ -0,0 +1,122 @@ +"""OAuth2 token validator for ACA-Py acting as a Resource Server.""" + +import logging +from typing import Optional + +import aiohttp +import jwt +from aiohttp import web + +LOGGER = logging.getLogger(__name__) + +_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" + ) + + # PyJWKClient handles JWKS fetching and caching internally. + self._jwks_client = ( + jwt.PyJWKClient(self.jwks_uri, cache_keys=True) if self.jwks_uri else 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 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" + ) + + def _validate_jwt(self, token: str) -> dict: + """Validate a JWT access token using the configured JWKS endpoint.""" + signing_key = self._jwks_client.get_signing_key_from_jwt(token) + + decode_kwargs = dict( + algorithms=_SUPPORTED_ALGORITHMS, + options={"require": ["exp", "iss", "sub"]}, + ) + 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 "", + ) + + async with aiohttp.ClientSession() as session: + resp = await session.post( + self.introspection_endpoint, + data={"token": token, "token_type_hint": "access_token"}, + auth=auth, + ) + if resp.status != 200: + raise web.HTTPUnauthorized( + reason=f"Token introspection returned HTTP {resp.status}" + ) + body = await resp.json() + + if not body.get("active"): + raise web.HTTPUnauthorized(reason="Token is not active") + + return body diff --git a/acapy_agent/admin/server.py b/acapy_agent/admin/server.py index 6a9c5efeb9..fbff63b71f 100644 --- a/acapy_agent/admin/server.py +++ b/acapy_agent/admin/server.py @@ -37,8 +37,10 @@ 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 .base_server import BaseAdminServer from .error import AdminSetupError +from .oauth_validator import OAuthTokenValidator from .request_context import AdminRequestContext from .routes import ( config_handler, @@ -302,14 +304,22 @@ 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 +328,38 @@ 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: + + 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")} + + 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 + except StorageNotFoundError: + raise web.HTTPUnauthorized( + reason=f"Wallet not found for wallet_id claim: {wallet_id}" + ) + + elif self.multitenant_manager and authorization_header: + # Legacy ACA-Py JWT path (multitenant without OAuth). try: bearer, _, token = authorization_header.partition(" ") if bearer != "Bearer": @@ -354,9 +394,7 @@ 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, @@ -535,29 +573,39 @@ 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 @@ -576,7 +624,21 @@ async def websocket_handler(self, request): queue = BasicMessageQueue() 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": + await self.oauth_validator.validate(token) + queue.authenticated = True + 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 +687,10 @@ 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( + 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 + # authenticated via websocket message (legacy api-key mode) queue.authenticated = True receive = loop.create_task(ws.receive_json()) diff --git a/acapy_agent/admin/tests/test_auth.py b/acapy_agent/admin/tests/test_auth.py index 765d7ef213..b9bc8b1d0f 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 @@ -174,3 +174,59 @@ 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) + + +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/config/argparse.py b/acapy_agent/config/argparse.py index e86d10ee1a..79f9093404 100644 --- a/acapy_agent/config/argparse.py +++ b/acapy_agent/config/argparse.py @@ -304,6 +304,69 @@ 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.", + ) def get_settings(self, args: Namespace): """Extract admin settings.""" @@ -311,18 +374,47 @@ def get_settings(self, args: Namespace): 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) + ) - 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." - ) + 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, unless --oauth-enabled (or " + "--oauth-jwks-uri / --oauth-introspection-endpoint) is configured." + ) settings["admin.admin_api_key"] = admin_api_key settings["admin.admin_insecure_mode"] = admin_insecure_mode + if oauth_mode: + settings["admin.oauth_enabled"] = True + + if getattr(args, "oauth_jwks_uri", None): + settings["oauth.jwks_uri"] = args.oauth_jwks_uri + if getattr(args, "oauth_issuer", None): + settings["oauth.issuer"] = args.oauth_issuer + if getattr(args, "oauth_audience", None): + settings["oauth.audience"] = args.oauth_audience + if getattr(args, "oauth_introspection_endpoint", None): + settings["oauth.introspection_endpoint"] = ( + args.oauth_introspection_endpoint + ) + if getattr(args, "oauth_introspection_client_id", None): + settings["oauth.introspection_client_id"] = ( + args.oauth_introspection_client_id + ) + if getattr(args, "oauth_introspection_client_secret", None): + settings["oauth.introspection_client_secret"] = ( + args.oauth_introspection_client_secret + ) + settings["admin.enabled"] = True settings["admin.host"] = args.admin[0] settings["admin.port"] = args.admin[1] diff --git a/acapy_agent/wallet/routes.py b/acapy_agent/wallet/routes.py index a366e87180..c6b300e26d 100644 --- a/acapy_agent/wallet/routes.py +++ b/acapy_agent/wallet/routes.py @@ -9,7 +9,7 @@ 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.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 +560,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. 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..df66044537 --- /dev/null +++ b/demo/demo-authserver/scripts/get-user-token.sh @@ -0,0 +1,272 @@ +#!/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." + 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." + 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." + echo " Response: ${TOKEN_RESPONSE}" + exit 1 +fi + +# ── output ──────────────────────────────────────────────────────────────────── + +echo "" +echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" +echo " User token for '${TEST_USERNAME}'" +echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" +echo "" +echo " Claims:" +echo "${ACCESS_TOKEN}" \ + | cut -d. -f2 \ + | (cat; echo) \ + | 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..bead5adc87 --- /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}'." + 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}'." + 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})." + 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})." + 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}'." + echo " Ensure the realm was imported from keycloak/realm-export.json." + 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})." + 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..84bf31a5a7 --- /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:-}" body="${3:-}" + 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." + echo " Run ./scripts/setup-tenant.sh first." + 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..743a8f03ef --- /dev/null +++ b/docs/features/OAuthResourceServer.md @@ -0,0 +1,351 @@ +# 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) +- [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-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. Optional but recommended for production. | +| `--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. | +| `--oauth-introspection-client-secret ` | Client secret for HTTP Basic Auth on the introspection endpoint. | + +When any of `--oauth-jwks-uri` or `--oauth-introspection-endpoint` is provided, **neither `--admin-api-key` nor `--admin-insecure-mode` is required**. + +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` | + +### 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. + +**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. + +**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 runs against the **base wallet** (suitable for admin operations with `acapy:admin` scope). + +### 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 + +--- + +## 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"`. + +The `scopes` set, `sub`, and `wallet_id` claims are available inside handlers via: + +```python +context = request["context"] +scopes: set = context.metadata.get("scopes", set()) +subject: str = context.metadata.get("sub") +wallet_id: str = context.metadata.get("wallet_id") +``` + +--- + +## 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. +- **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.