Skip to content
Open
Show file tree
Hide file tree
Changes from 15 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
75 changes: 75 additions & 0 deletions acapy_agent/admin/auth_context.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
"""Helpers for reading request-scoped auth context safely.

These helpers support both:

1) Preferred request metadata (``AdminRequestContext.metadata``), and
2) Request-scoped settings fallback for compatibility.
"""

from collections.abc import Mapping
from typing import Any, Optional

AUTH_SCOPES_SETTING = "auth.scopes"
AUTH_SUBJECT_SETTING = "auth.subject"
AUTH_WALLET_ID_SETTING = "auth.wallet_id"


def _get_context_settings(context: Any) -> Mapping[str, Any]:
settings = getattr(context, "settings", None)
if isinstance(settings, Mapping):
return settings
return {}


def get_auth_metadata(context: Any) -> dict:
"""Return auth metadata when available, else an empty dict."""
metadata = getattr(context, "metadata", None)
if isinstance(metadata, dict):
return metadata
return {}


def has_auth_scopes(context: Any) -> bool:
"""Whether auth scopes are present on the request context."""
metadata = get_auth_metadata(context)
if "scopes" in metadata:
return True
settings = _get_context_settings(context)
return AUTH_SCOPES_SETTING in settings


def get_auth_scopes(context: Any) -> set[str]:
"""Return normalized auth scopes from metadata or request settings."""
metadata = get_auth_metadata(context)
raw_scopes = metadata.get("scopes")
if raw_scopes is None:
raw_scopes = _get_context_settings(context).get(AUTH_SCOPES_SETTING, ())

if isinstance(raw_scopes, str):
return set(raw_scopes.split())
if isinstance(raw_scopes, (set, list, tuple, frozenset)):
return {scope for scope in raw_scopes if isinstance(scope, str)}
return set()


def get_auth_subject(context: Any) -> Optional[str]:
"""Return token subject claim from metadata or request settings."""
metadata = get_auth_metadata(context)
subject = metadata.get("sub")
if subject is None:
subject = _get_context_settings(context).get(AUTH_SUBJECT_SETTING)
return subject if isinstance(subject, str) else None


def get_auth_wallet_id(context: Any) -> Optional[str]:
"""Return request wallet id from metadata or request settings."""
metadata = get_auth_metadata(context)
wallet_id = metadata.get("wallet_id")
if wallet_id is None:
wallet_id = _get_context_settings(context).get(AUTH_WALLET_ID_SETTING)
return wallet_id if isinstance(wallet_id, str) else None


def has_auth_wallet_id(context: Any) -> bool:
"""Whether request context carries a wallet_id claim."""
return bool(get_auth_wallet_id(context))
114 changes: 96 additions & 18 deletions acapy_agent/admin/decorators/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@
from aiohttp import web

from ...utils import general as general_utils
from .. import scopes
from ..auth_context import get_auth_scopes, has_auth_scopes
from ..request_context import AdminRequestContext


Expand All @@ -22,23 +24,33 @@ def admin_authentication(handler):
async def admin_auth(request):
context: AdminRequestContext = request["context"]
profile = context.profile

if request.method == "OPTIONS":
return await handler(request)

# OAuth path: token was validated by setup_context middleware.
# Require acapy:admin scope for admin-only routes.
if has_auth_scopes(context):
if scopes.ADMIN in get_auth_scopes(context):
return await handler(request)
raise web.HTTPForbidden(
reason=f"{scopes.ADMIN} scope required",
text=f"{scopes.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

Expand All @@ -58,6 +70,15 @@ def tenant_authentication(handler):
async def tenant_auth(request):
context: AdminRequestContext = request["context"]
profile = context.profile

if request.method == "OPTIONS":
return await handler(request)

# OAuth path: token was validated by setup_context middleware.
if has_auth_scopes(context):
_enforce_tenant_scope(get_auth_scopes(context), request.method)
return await handler(request)

authorization_header = request.headers.get("Authorization")
header_admin_api_key = request.headers.get("x-api-key")
valid_key = general_utils.const_compare(
Expand All @@ -73,25 +94,82 @@ async def tenant_auth(request):
request.path,
)

# CORS fix: allow OPTIONS method access to paths without a token
if (
(multitenant_enabled and authorization_header)
or (not multitenant_enabled and valid_key)
or (multitenant_enabled and valid_key and base_wallet_allowed_route)
or (insecure_mode and not multitenant_enabled)
or request.method == "OPTIONS"
):
return await handler(request)
else:
auth_mode = "Authorization token" if multitenant_enabled else "API key"
raise web.HTTPUnauthorized(
reason=f"{auth_mode} missing or invalid",
text=f"{auth_mode} missing or invalid",
)

auth_mode = "Authorization token" if multitenant_enabled else "API key"
raise web.HTTPUnauthorized(
reason=f"{auth_mode} missing or invalid",
text=f"{auth_mode} missing or invalid",
)

return tenant_auth


def require_scope(*required_scopes: str):
"""Require at least one of the given OAuth2 scopes on the request token.

No-op when OAuth mode is not enabled (admin.oauth_enabled is not True), so
routes decorated with require_scope continue to work with API key / insecure
mode without any changes.

Must be stacked inside tenant_authentication or admin_authentication so that
authentication is checked before scope enforcement.

Example::

@tenant_authentication
@require_scope("acapy:tenant", "acapy:admin")
async def my_handler(request): ...
"""

def decorator(handler):
@functools.wraps(handler)
async def scope_check(request):
if request.method == "OPTIONS":
return await handler(request)
context: AdminRequestContext = request["context"]
if not context.profile.settings.get("admin.oauth_enabled"):
return await handler(request)
token_scopes = get_auth_scopes(context)
if not token_scopes.intersection(required_scopes):
raise web.HTTPForbidden(
reason="Insufficient scope",
text="Insufficient scope",
)
return await handler(request)

return scope_check

return decorator


def _enforce_tenant_scope(token_scopes: set, method: str) -> None:
"""Authorize a tenant request by its OAuth scopes, else raise HTTPForbidden.

``acapy:tenant`` or ``acapy:admin`` grant tenant-level access;
``acapy:tenant:read`` grants read-only access (safe HTTP methods only).
"""
if token_scopes & scopes.TENANT_LEVEL:
return
if scopes.TENANT_READ in token_scopes:
if method in ("GET", "HEAD"):
return
raise web.HTTPForbidden(
reason=f"{scopes.TENANT_READ} scope does not permit write operations",
text=f"{scopes.TENANT_READ} scope does not permit write operations",
)
raise web.HTTPForbidden(
reason=f"{scopes.TENANT} scope required",
text=f"{scopes.TENANT} scope required",
)


def _base_wallet_route_access(additional_routes: List[str], request_path: str) -> bool:
"""Check if request path matches additional routes."""
additional_routes_pattern = (
Expand Down
126 changes: 126 additions & 0 deletions acapy_agent/admin/oauth_context.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
"""OAuth2 request authentication for the admin API.

Keeps the OAuth Resource Server request and websocket authorization logic out
of the admin server module. An instance is created by ``AdminServer`` when
OAuth mode is active.
"""

from typing import Tuple

from aiohttp import web

from ..config.logging import context_wallet_id
from ..storage.error import StorageNotFoundError
from ..wallet.models.wallet_record import WalletRecord
from . import scopes
from .auth_context import (
AUTH_SCOPES_SETTING,
AUTH_SUBJECT_SETTING,
AUTH_WALLET_ID_SETTING,
)


class OAuthRequestAuthenticator:
"""Authenticate admin API requests and websockets against OAuth2 tokens."""

def __init__(self, validator, multitenant_manager, root_profile, context):
"""Initialize the authenticator with the collaborators it needs."""
self.validator = validator
self.multitenant_manager = multitenant_manager
self.root_profile = root_profile
self.context = context

async def authenticate_request(self, authorization_header: str) -> Tuple:
"""Validate the bearer token and derive the request profile/context.

Args:
authorization_header: the raw ``Authorization`` header value.

Returns:
A ``(profile, meta_data, request_settings)`` tuple for building the
request's ``AdminRequestContext``.

Raises:
web.HTTPUnauthorized: the header is malformed, the token is invalid,
or (in multitenant mode) the token lacks a ``wallet_id`` claim
without carrying the admin scope.

"""
bearer, _, token = authorization_header.partition(" ")
if bearer != "Bearer":
raise web.HTTPUnauthorized(reason="Invalid Authorization header structure")

claims = await self.validator.validate(token)
token_scopes = set(claims.get("scope", "").split())
meta_data = {"scopes": token_scopes, "sub": claims.get("sub")}
request_settings = {AUTH_SCOPES_SETTING: tuple(sorted(token_scopes))}
if claims.get("sub"):
request_settings[AUTH_SUBJECT_SETTING] = claims["sub"]

profile = self.root_profile
if self.multitenant_manager:
profile = await self._resolve_wallet_profile(
claims, token_scopes, meta_data, request_settings
)
return profile, meta_data, request_settings

async def _resolve_wallet_profile(
self, claims, token_scopes, meta_data, request_settings
):
"""Select the sub-wallet profile from the token's ``wallet_id`` claim."""
wallet_id = claims.get("wallet_id")
if not wallet_id:
if scopes.ADMIN not in token_scopes:
# Without a wallet_id claim the request would run against the
# base wallet; only admin-scoped tokens may do that.
raise web.HTTPUnauthorized(
reason=(
"Token must include a wallet_id claim (or the "
"acapy:admin scope) in multitenant mode"
)
)
return self.root_profile

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
)
except StorageNotFoundError:
raise web.HTTPUnauthorized(
reason=f"Wallet not found for wallet_id claim: {wallet_id}"
)
context_wallet_id.set(wallet_id)
meta_data["wallet_id"] = wallet_id
request_settings[AUTH_WALLET_ID_SETTING] = wallet_id
return profile

def authorize_websocket(self, claims: dict, queue) -> None:
"""Set websocket authorization on the queue from validated token claims.

Mirrors the HTTP auth decorators so the admin event stream honours the
same scope and tenant-isolation rules:

- ``acapy:admin`` receives the full cross-wallet event stream.
- ``acapy:tenant`` / ``acapy:tenant:read`` receives only its own
wallet's events; in multitenant mode a ``wallet_id`` claim is
required.
- Any other token is treated as unauthenticated (no events delivered).
"""
token_scopes = set(claims.get("scope", "").split())
wallet_id = claims.get("wallet_id")

if scopes.ADMIN in token_scopes:
queue.authenticated = True
queue.receive_all = True
elif token_scopes & {scopes.TENANT, scopes.TENANT_READ}:
if self.multitenant_manager and not wallet_id:
# A tenant token with no wallet binding must not receive events.
queue.authenticated = False
else:
queue.authenticated = True
queue.wallet_id = wallet_id
queue.receive_all = not self.multitenant_manager
else:
queue.authenticated = False
Loading