Skip to content

Commit 51e0468

Browse files
committed
added example require_scope
Signed-off-by: Tim Bloomfield <tim.bloomfield@ontario.ca>
1 parent d5c1e1d commit 51e0468

9 files changed

Lines changed: 326 additions & 40 deletions

File tree

acapy_agent/admin/decorators/auth.py

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -126,8 +126,12 @@ async def tenant_auth(request):
126126
def require_scope(*required_scopes: str):
127127
"""Require at least one of the given OAuth2 scopes on the request token.
128128
129-
Must be applied after tenant_authentication (or any decorator that populates
130-
request["context"].metadata["scopes"]).
129+
No-op when OAuth mode is not enabled (admin.oauth_enabled is not True), so
130+
routes decorated with require_scope continue to work with API key / insecure
131+
mode without any changes.
132+
133+
Must be stacked inside tenant_authentication or admin_authentication so that
134+
authentication is checked before scope enforcement.
131135
132136
Example::
133137
@@ -142,6 +146,8 @@ async def scope_check(request):
142146
if request.method == "OPTIONS":
143147
return await handler(request)
144148
context: AdminRequestContext = request["context"]
149+
if not context.profile.settings.get("admin.oauth_enabled"):
150+
return await handler(request)
145151
token_scopes: set = (context.metadata or {}).get("scopes", set())
146152
if not token_scopes.intersection(required_scopes):
147153
raise web.HTTPForbidden(

acapy_agent/admin/server.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -305,7 +305,8 @@ def __init__(
305305
self.multitenant_manager = context.inject_or(BaseMultitenantManager)
306306

307307
oauth_mode = bool(
308-
context.settings.get("oauth.jwks_uri")
308+
context.settings.get("admin.oauth_enabled")
309+
or context.settings.get("oauth.jwks_uri")
309310
or context.settings.get("oauth.introspection_endpoint")
310311
)
311312
self.oauth_validator = OAuthTokenValidator(context.settings) if oauth_mode else None

acapy_agent/admin/tests/test_auth.py

Lines changed: 57 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44

55
from ...tests import mock
66
from ...utils.testing import create_test_profile
7-
from ..decorators.auth import admin_authentication, tenant_authentication
7+
from ..decorators.auth import admin_authentication, require_scope, tenant_authentication
88
from ..request_context import AdminRequestContext
99

1010

@@ -174,3 +174,59 @@ async def test_base_wallet_additional_route_denied(self):
174174
decor_func = tenant_authentication(self.decorated_handler)
175175
with self.assertRaises(web.HTTPUnauthorized):
176176
await decor_func(self.request)
177+
178+
179+
class TestRequireScope(IsolatedAsyncioTestCase):
180+
async def asyncSetUp(self) -> None:
181+
self.profile = await create_test_profile()
182+
self.decorated_handler = mock.CoroutineMock()
183+
184+
def _make_request(self, scopes=None, method="POST"):
185+
metadata = {"scopes": set(scopes)} if scopes is not None else None
186+
context = AdminRequestContext(profile=self.profile, metadata=metadata)
187+
return mock.MagicMock(
188+
__getitem__=lambda _, k: {"context": context}[k],
189+
headers={},
190+
method=method,
191+
)
192+
193+
async def test_options_always_passes(self):
194+
request = self._make_request(method="OPTIONS")
195+
decor = require_scope("acapy:tenant")(self.decorated_handler)
196+
await decor(request)
197+
self.decorated_handler.assert_called_once_with(request)
198+
199+
async def test_non_oauth_mode_passes_without_scopes(self):
200+
"""require_scope is a no-op when admin.oauth_enabled is not set."""
201+
request = self._make_request() # no metadata / no scopes
202+
decor = require_scope("acapy:tenant")(self.decorated_handler)
203+
await decor(request)
204+
self.decorated_handler.assert_called_once_with(request)
205+
206+
async def test_oauth_mode_passes_with_required_scope(self):
207+
self.profile.settings["admin.oauth_enabled"] = True
208+
request = self._make_request(scopes=["acapy:tenant"])
209+
decor = require_scope("acapy:tenant", "acapy:admin")(self.decorated_handler)
210+
await decor(request)
211+
self.decorated_handler.assert_called_once_with(request)
212+
213+
async def test_oauth_mode_admin_scope_satisfies_any_requirement(self):
214+
self.profile.settings["admin.oauth_enabled"] = True
215+
request = self._make_request(scopes=["acapy:admin"])
216+
decor = require_scope("acapy:wallet:create", "acapy:admin")(self.decorated_handler)
217+
await decor(request)
218+
self.decorated_handler.assert_called_once_with(request)
219+
220+
async def test_oauth_mode_raises_403_on_insufficient_scope(self):
221+
self.profile.settings["admin.oauth_enabled"] = True
222+
request = self._make_request(scopes=["acapy:tenant:read"])
223+
decor = require_scope("acapy:wallet:create", "acapy:admin")(self.decorated_handler)
224+
with self.assertRaises(web.HTTPForbidden):
225+
await decor(request)
226+
227+
async def test_oauth_mode_raises_403_when_no_scopes_in_token(self):
228+
self.profile.settings["admin.oauth_enabled"] = True
229+
request = self._make_request(scopes=[])
230+
decor = require_scope("acapy:tenant")(self.decorated_handler)
231+
with self.assertRaises(web.HTTPForbidden):
232+
await decor(request)

acapy_agent/config/argparse.py

Lines changed: 21 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -304,6 +304,18 @@ def add_arguments(self, parser: ArgumentParser):
304304
env_var="ACAPY_ADMIN_CLIENT_MAX_REQUEST_SIZE",
305305
help="Maximum client request size to admin server, in megabytes: default 1",
306306
)
307+
parser.add_argument(
308+
"--oauth-enabled",
309+
action="store_true",
310+
env_var="ACAPY_OAUTH_ENABLED",
311+
help=(
312+
"Enable OAuth2 Resource Server mode. ACA-Py will accept Bearer tokens "
313+
"issued by an external Authorization Server and enforce scope-based "
314+
"access control. Neither --admin-api-key nor --admin-insecure-mode is "
315+
"required when this flag is set. Usually combined with --oauth-jwks-uri "
316+
"and/or --oauth-introspection-endpoint."
317+
),
318+
)
307319
parser.add_argument(
308320
"--oauth-jwks-uri",
309321
type=str,
@@ -312,8 +324,8 @@ def add_arguments(self, parser: ArgumentParser):
312324
help=(
313325
"JWKS endpoint of the OAuth2 Authorization Server used to validate "
314326
"JWT access tokens (e.g. https://as.example.com/.well-known/jwks.json). "
315-
"When set, ACA-Py acts as an OAuth2 Resource Server and neither "
316-
"--admin-api-key nor --admin-insecure-mode is required."
327+
"Implicitly enables --oauth-enabled. Neither --admin-api-key nor "
328+
"--admin-insecure-mode is required when this is set."
317329
),
318330
)
319331
parser.add_argument(
@@ -363,7 +375,8 @@ def get_settings(self, args: Namespace):
363375
admin_api_key = args.admin_api_key
364376
admin_insecure_mode = args.admin_insecure_mode
365377
oauth_mode = bool(
366-
getattr(args, "oauth_jwks_uri", None)
378+
getattr(args, "oauth_enabled", False)
379+
or getattr(args, "oauth_jwks_uri", None)
367380
or getattr(args, "oauth_introspection_endpoint", None)
368381
)
369382

@@ -373,13 +386,16 @@ def get_settings(self, args: Namespace):
373386
):
374387
raise ArgsParseError(
375388
"Either --admin-api-key or --admin-insecure-mode "
376-
"must be set but not both, unless --oauth-jwks-uri or "
377-
"--oauth-introspection-endpoint is configured."
389+
"must be set but not both, unless --oauth-enabled (or "
390+
"--oauth-jwks-uri / --oauth-introspection-endpoint) is configured."
378391
)
379392

380393
settings["admin.admin_api_key"] = admin_api_key
381394
settings["admin.admin_insecure_mode"] = admin_insecure_mode
382395

396+
if oauth_mode:
397+
settings["admin.oauth_enabled"] = True
398+
383399
if getattr(args, "oauth_jwks_uri", None):
384400
settings["oauth.jwks_uri"] = args.oauth_jwks_uri
385401
if getattr(args, "oauth_issuer", None):

acapy_agent/wallet/routes.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
from aiohttp_apispec import docs, querystring_schema, request_schema, response_schema
1010
from marshmallow import fields, validate
1111

12-
from ..admin.decorators.auth import tenant_authentication
12+
from ..admin.decorators.auth import require_scope, tenant_authentication
1313
from ..admin.request_context import AdminRequestContext
1414
from ..config.injection_context import InjectionContext
1515
from ..connections.base_manager import BaseConnectionManager
@@ -560,6 +560,7 @@ async def wallet_did_list(request: web.BaseRequest):
560560
@request_schema(DIDCreateSchema())
561561
@response_schema(DIDResultSchema, 200, description="")
562562
@tenant_authentication
563+
@require_scope("acapy:wallet:create", "acapy:admin")
563564
async def wallet_create_did(request: web.BaseRequest):
564565
"""Request handler for creating a new local DID in the wallet.
565566

demo/demo-authserver/keycloak/realm-export.json

Lines changed: 50 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -101,7 +101,7 @@
101101
{
102102
"clientId": "acapy-tenant-demo",
103103
"name": "ACA-Py Demo Tenant",
104-
"description": "Demo tenant client. The wallet_id claim is set to PLACEHOLDER by default and updated by setup-tenant.sh.",
104+
"description": "Demo tenant client with full write access including DID creation. The wallet_id claim is updated by setup-tenant.sh.",
105105
"enabled": true,
106106
"protocol": "openid-connect",
107107
"bearerOnly": false,
@@ -113,7 +113,8 @@
113113
"clientAuthenticatorType": "client-secret",
114114
"secret": "tenant-secret",
115115
"defaultClientScopes": [
116-
"acapy:tenant"
116+
"acapy:tenant",
117+
"acapy:wallet:create"
117118
],
118119
"optionalClientScopes": [
119120
"acapy:tenant:read"
@@ -146,5 +147,52 @@
146147
}
147148
]
148149
}
150+
,
151+
{
152+
"clientId": "acapy-tenant-limited",
153+
"name": "ACA-Py Limited Tenant",
154+
"description": "Tenant client with acapy:tenant but WITHOUT acapy:wallet:create — used to demonstrate require_scope enforcement on /wallet/did/create.",
155+
"enabled": true,
156+
"protocol": "openid-connect",
157+
"bearerOnly": false,
158+
"publicClient": false,
159+
"serviceAccountsEnabled": true,
160+
"standardFlowEnabled": false,
161+
"implicitFlowEnabled": false,
162+
"directAccessGrantsEnabled": false,
163+
"clientAuthenticatorType": "client-secret",
164+
"secret": "limited-secret",
165+
"defaultClientScopes": [
166+
"acapy:tenant"
167+
],
168+
"optionalClientScopes": [],
169+
"protocolMappers": [
170+
{
171+
"name": "audience-acapy",
172+
"protocol": "openid-connect",
173+
"protocolMapper": "oidc-audience-mapper",
174+
"consentRequired": false,
175+
"config": {
176+
"included.client.audience": "acapy-resource-server",
177+
"id.token.claim": "false",
178+
"access.token.claim": "true"
179+
}
180+
},
181+
{
182+
"name": "wallet-id",
183+
"protocol": "openid-connect",
184+
"protocolMapper": "oidc-hardcoded-claim-mapper",
185+
"consentRequired": false,
186+
"config": {
187+
"claim.name": "wallet_id",
188+
"claim.value": "PLACEHOLDER_WALLET_ID",
189+
"jsonType.label": "String",
190+
"id.token.claim": "false",
191+
"access.token.claim": "true",
192+
"userinfo.token.claim": "false"
193+
}
194+
}
195+
]
196+
}
149197
]
150198
}

demo/demo-authserver/oauth-scope-test-report.md

Lines changed: 18 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# OAuth Scope Test Report
22

3-
**Date:** 2026-05-24 22:34:25
3+
**Date:** 2026-05-26 18:58:41
44
**ACA-Py:** http://localhost:8031
55
**Keycloak realm:** http://localhost:8080/realms/acapy
66
**Wallet ID under test:** b3ea2232-f617-4f95-b90c-35409d2b8d44
@@ -9,9 +9,9 @@
99

1010
| Result | Count |
1111
|--------|-------|
12-
| Passed | 27 |
12+
| Passed | 31 |
1313
| Failed | 0 |
14-
| Total | 27 |
14+
| Total | 31 |
1515

1616
## Test Cases
1717

@@ -44,17 +44,21 @@
4444
| Read-only | GET /wallet/did — readonly token | 200 | 200 | PASS |
4545
| Read-only | POST /wallet/did/create — readonly token | 403 | 403 | PASS |
4646
| Read-only | GET /multitenancy/wallets — readonly token | 403 | 403 | PASS |
47+
| wallet:create scope | GET /connections — limited token (acapy:tenant, no wallet:create) | 200 | 200 | PASS |
48+
| wallet:create scope | POST /wallet/did/create — limited token (missing acapy:wallet:create) | 403 | 403 | PASS |
49+
| wallet:create scope | POST /wallet/did/create — tenant token (has acapy:wallet:create) | 200 | 200 | PASS |
50+
| wallet:create scope | POST /wallet/did/create — admin token (acapy:admin satisfies require_scope) | 200 | 200 | PASS |
4751

4852
## Scope Matrix
4953

50-
| Endpoint | No token | Invalid token | acapy:admin | acapy:tenant | acapy:tenant:read |
51-
|----------|----------|---------------|-------------|--------------|-------------------|
52-
| `GET /status/ready` | 200 | 200 | 200 | 200 | 200 |
53-
| `GET /status/config` | 401 | 401 | 200 | 403 | 403 |
54-
| `GET /multitenancy/wallets` | 401 | 401 | 200 | 403 | 403 |
55-
| `GET /multitenancy/wallet/{id}` | 401 | 401 | 200 | 403 | 403 |
56-
| `GET /connections` | 401 | 401 | 200 | 200 | 200 |
57-
| `GET /credentials` | 401 | 401 | 200 | 200 | 200 |
58-
| `GET /wallet/did` | 401 | 401 | 200 | 200 | 200 |
59-
| `POST /wallet/did/create` | 401 | 401 | 200 | 200 | 403 |
60-
| `POST /multitenancy/wallet/{id}/remove` | 401 | 401 | 200 | 403 | 403 |
54+
| Endpoint | No token | Invalid token | acapy:admin | acapy:tenant + acapy:wallet:create | acapy:tenant (no wallet:create) | acapy:tenant:read |
55+
|----------|----------|---------------|-------------|-------------------------------------|----------------------------------|-------------------|
56+
| `GET /status/ready` | 200 | 200 | 200 | 200 | 200 | 200 |
57+
| `GET /status/config` | 401 | 401 | 200 | 403 | 403 | 403 |
58+
| `GET /multitenancy/wallets` | 401 | 401 | 200 | 403 | 403 | 403 |
59+
| `GET /multitenancy/wallet/{id}` | 401 | 401 | 200 | 403 | 403 | 403 |
60+
| `GET /connections` | 401 | 401 | 200 | 200 | 200 | 200 |
61+
| `GET /credentials` | 401 | 401 | 200 | 200 | 200 | 200 |
62+
| `GET /wallet/did` | 401 | 401 | 200 | 200 | 200 | 200 |
63+
| `POST /wallet/did/create` | 401 | 401 | 200 | 200 | **403** | 403 |
64+
| `POST /multitenancy/wallet/{id}/remove` | 401 | 401 | 200 | 403 | 403 | 403 |

0 commit comments

Comments
 (0)