Skip to content

POC: experimental Oauth support#4140

Open
timbl-ont wants to merge 18 commits into
openwallet-foundation:mainfrom
timbl-ont:POC-oauth-support
Open

POC: experimental Oauth support#4140
timbl-ont wants to merge 18 commits into
openwallet-foundation:mainfrom
timbl-ont:POC-oauth-support

Conversation

@timbl-ont

Copy link
Copy Markdown

Description

This is a POC to demonstrate adding OAuth support to ACA-Py. The code was developed via AI and is not expected to be merged as is without appropriate developer input and testing. Contributed here as per discussions at the ACA-Py OWF project meeting today.

The code does the following:

  • Adds an OAuth validator - JWT (JWKS) and introspection
  • Updates the auth decorators, admin_authentication and tenant_authentication, to support basic scopes that provide similar functionality to x-api-key and tenant JWT.
  • Adds a decorator, require_scope, which can be added to any route to enable granular permissions. Example provided for wallet_create_did
  • Demo created with Keycloak under ./demo/demo-authserver. Run docker compose up
  • Various test scripts in ./demo/demo-authserver/scripts
  • Documentation in ./docs/features/OAuthResourceServer.md

One area of exploration may be to add an orthogonal set of scopes that represent granular roles with ACA-Py.

Type of Change

  • Bug fix
  • [X ] New feature (demo)
  • Documentation update
  • Chore / maintenance

Checklist

  • I have read the Contributing Guide
  • My changes follow the existing code style
  • I have added/updated tests where applicable
  • I have updated documentation if needed

timbl-ont and others added 5 commits May 26, 2026 13:58
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Tim Bloomfield <tim.bloomfield@ontario.ca>
Signed-off-by: timbl-ont <tim.bloomfield@ontario.ca>
Signed-off-by: timbl-ont <tim.bloomfield@ontario.ca>
Signed-off-by: timbl-ont <tim.bloomfield@ontario.ca>
Signed-off-by: Tim Bloomfield <tim.bloomfield@ontario.ca>
@timbl-ont
timbl-ont marked this pull request as draft May 26, 2026 23:18
Signed-off-by: timbl-ont <tim.bloomfield@ontario.ca>
@timbl-ont

Copy link
Copy Markdown
Author

@swcurran can you approve the test workflows. Tests are passing on my local environment.

@timbl-ont
timbl-ont marked this pull request as ready for review June 9, 2026 15:46
Signed-off-by: timbl-ont <tim.bloomfield@ontario.ca>
@timbl-ont

Copy link
Copy Markdown
Author

@jamshale @PatStLouis @weiiv if you have the time can you review this PR and provide some feedback on potential readiness to merge and if not what types of remediations are required. The tests are passing locally on my laptop but have not yet run on the latest code push.

I see two possible paths:

  1. Work directly on this PR to get it in the appropriate shape to merge
  2. Use this POC as inspiration for a new PR

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR introduces a proof-of-concept OAuth2 “Resource Server” mode for ACA-Py’s Admin API, enabling external authorization servers (e.g., Keycloak) to issue bearer tokens that ACA-Py validates and authorizes via scopes, alongside a runnable demo environment and accompanying documentation.

Changes:

  • Add OAuth token validation (JWKS JWT validation + RFC7662 introspection fallback) and wire it into admin request context setup.
  • Extend admin/tenant auth decorators for scope-based authorization and add require_scope + auth-context helper utilities.
  • Add a Keycloak + Docker Compose demo (scripts, realm export) and documentation describing configuration, scopes, and request flow.

Reviewed changes

Copilot reviewed 21 out of 21 changed files in this pull request and generated 5 comments.

Show a summary per file
File Description
docs/features/OAuthResourceServer.md Documents OAuth2 RS mode, scopes, configuration, and migration guidance.
demo/demo-authserver/scripts/test-oauth-scopes.sh Adds an automated scope enforcement test script for the demo deployment.
demo/demo-authserver/scripts/start-acapy.sh Starts ACA-Py in the demo container with OAuth RS settings enabled.
demo/demo-authserver/scripts/setup-tenant.sh Provisions a demo sub-wallet and configures Keycloak wallet_id claim + scopes.
demo/demo-authserver/scripts/get-user-token.sh Demonstrates auth-code + PKCE login flow and prints resulting token/claims.
demo/demo-authserver/scripts/get-tenant-token.sh Retrieves tenant access tokens via client credentials for demo testing.
demo/demo-authserver/scripts/get-admin-token.sh Retrieves admin access tokens via client credentials for demo testing.
demo/demo-authserver/README.md Provides end-to-end instructions for running and using the OAuth demo.
demo/demo-authserver/oauth-scope-test-report.md Example output report showing expected scope enforcement results.
demo/demo-authserver/keycloak/realm-export.json Keycloak realm export defining demo clients, scopes, and mappers.
demo/demo-authserver/docker-compose.yml Composes Keycloak + Postgres + ACA-Py into a runnable demo stack.
demo/demo-authserver/.gitignore Ignores the demo .env file.
acapy_agent/wallet/routes.py Adds require_scope to DID creation and uses auth-context helpers for subwallet detection.
acapy_agent/settings/routes.py Hardens metadata access to tolerate missing context.metadata safely.
acapy_agent/config/argparse.py Adds OAuth CLI flags and updates admin auth requirement rules for OAuth mode.
acapy_agent/admin/tests/test_auth.py Adds unit tests for the new require_scope decorator behavior.
acapy_agent/admin/tests/test_auth_context.py Adds tests for auth-context helper functions and settings fallback.
acapy_agent/admin/server.py Integrates OAuth validation into request context setup and Swagger auth definitions.
acapy_agent/admin/oauth_validator.py Implements OAuth token validation via JWKS and introspection fallback.
acapy_agent/admin/decorators/auth.py Extends auth decorators to enforce OAuth scopes and adds require_scope.
acapy_agent/admin/auth_context.py Introduces helpers to read scopes/subject/wallet_id from metadata/settings consistently.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread acapy_agent/config/argparse.py
Comment thread acapy_agent/admin/server.py Outdated
Comment thread acapy_agent/admin/oauth_validator.py Outdated
Comment on lines +114 to +125
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()

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Configurable timeout added

Comment thread demo/demo-authserver/scripts/get-user-token.sh
Comment on lines +87 to +99
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
}

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed

timbl-ont and others added 3 commits July 6, 2026 17:15
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Signed-off-by: timbl-ont <163455524+timbl-ont@users.noreply.github.com>
Review feedback:
- Fail fast in argparse when OAuth mode is enabled without a token
  validation method, and when introspection is configured without a
  client id.
- Reject tenant tokens with no wallet_id claim in multitenant OAuth mode
  unless the token carries acapy:admin (prevents fall-through to the base
  wallet).
- Harden RFC 7662 introspection: shared session with a configurable
  timeout, scoped response, and network errors surfaced as 503.
- Only verify aud when an audience is configured (fixes rejection of
  tokens carrying an aud claim when none is expected).
- Remove the unused body parameter from the demo test-oauth-scopes.sh
  http_post_form helper.

Configurable AS HTTP timeout:
- Add --oauth-http-timeout (default 10s) applied to both the introspection
  session and the PyJWKClient.
- Run the blocking JWKS key fetch in an executor so a slow AS cannot stall
  the event loop.

Security audit fixes:
- Scope- and wallet-aware admin event websocket: an acapy:admin token
  receives all wallets' events, a tenant token only its own wallet's, and
  send_webhook filters delivery per connection (fixes cross-tenant leak).
- Redact oauth.introspection_client_secret from GET /status/config.
- Require --oauth-audience whenever --oauth-jwks-uri is set so JWT access
  tokens are bound to this resource server (fail closed).

Adds unit tests for the validator, admin server OAuth middleware and
websocket authorization, argparse validation, and auth decorators; updates
the OAuthResourceServer docs.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Tim Bloomfield <tim.bloomfield@ontario.ca>
- oauth_validator: use a dict literal instead of the dict() constructor
  (python:S7498).
- Extract the OAuth scope check from tenant_authentication into
  _enforce_tenant_scope to reduce cognitive complexity (python:S3776).
- Extract OAuth validation and settings mapping from AdminGroup.get_settings
  into _validate_admin_auth_args and _oauth_settings helpers, reducing its
  cognitive complexity and collapsing the nested if (S3776, S1066).
- Redirect demo-script ERROR messages to stderr (shell S7677).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Tim Bloomfield <tim.bloomfield@ontario.ca>
@timbl-ont

Copy link
Copy Markdown
Author

@jamshale Pushed fixes for the ChatGPT code review, sonar cloud issues (for those that made sense) as well as some fixes as a result of a security audit using Claude Fable.

@timbl-ont

Copy link
Copy Markdown
Author

Quality Gate Failed Quality Gate failed

Failed conditions 1 Security Hotspot

See analysis details on SonarQube Cloud

Is there a way to dismiss this finding as it is a false positive?

@swcurran

swcurran commented Jul 7, 2026

Copy link
Copy Markdown
Contributor

The easy approach is that the technical reviewers approve the PR with a note saying that the SonorCloud fail is a false negative and we merge it regardless. For a little more work, someone looks into why SonarCloud is identifying the issue and update its config to ignore that (or that class of) error. I don't know the effort in that. AI could probably help in providing guidance on how to do that.

I'm good with the reviewers noting that the its a false negative.

@jamshale
jamshale requested a review from esune July 8, 2026 15:45

@jamshale jamshale left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This looks good to me 👍 A couple small comments and suggestions.

I'm also wondering that if instead of the demo we could move this to a scenario example with some basic tests scenarios/examples. Then we could have this feature protected during development PRs.

Comment thread acapy_agent/admin/decorators/auth.py Outdated
``acapy:tenant`` or ``acapy:admin`` grant tenant-level access;
``acapy:tenant:read`` grants read-only access (safe HTTP methods only).
"""
if scopes & {"acapy:tenant", "acapy:admin"}:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could make these scope strings constants for maintainability.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done

Comment thread acapy_agent/admin/server.py Outdated
if self.multitenant_manager and authorization_header:
request_settings = {}

if self.oauth_validator and authorization_header:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This does add a lot of code to an already overloaded file. Should consider moving this added logic to a service or util class.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The code has been refactored.

Comment thread acapy_agent/wallet/routes.py Outdated
@request_schema(DIDCreateSchema())
@response_schema(DIDResultSchema, 200, description="")
@tenant_authentication
@require_scope("acapy:wallet:create", "acapy:admin")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Once again, I think these scope string should be defined as constants somewhere appropriate.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done

Responds to jamshale's PR feedback:

- Add acapy_agent/admin/scopes.py defining the OAuth scope identifiers
  (ADMIN / TENANT / TENANT_READ / WALLET_CREATE) and replace the scattered
  string literals in the auth decorators, admin server, and wallet routes.
- Extract the OAuth Resource Server request-authentication logic out of the
  already-large admin/server.py into a dedicated
  OAuthRequestAuthenticator (admin/oauth_context.py). It owns
  authenticate_request() (the setup_context bearer-token branch, wallet_id
  resolution and rejection) and authorize_websocket() (the admin event
  stream scope/wallet gating). server.py now delegates to it.

Moves the websocket-authorization unit tests to a focused
test_oauth_context.py that also covers authenticate_request directly, and
repoints the admin-server integration tests at the new module.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Tim Bloomfield <tim.bloomfield@ontario.ca>

@esune esune left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This looks quite good, thanks @timbl-ont for this PR!

I think the follow-up with granular set of scopes would be great, fine-grained permissions would definitely improve security of deployments.

With this in mind, a couple of comments/ideas:

  • I do realize it would mean adding an additional dependency, but what are the thoughts around using a certified OpenID Connect library to manage everything OIDC-related? This would allow us to consume the .well-known endpoint of the issuer to gather most (all?) configuration settings, rather than having several configuration arguments for JWKS, introspection, etc.
  • Using an OIDC library would also allow ACA-Py to publish its own .well-known endpoint. This would expose the supported scopes providing an easy way to discover (and likely maintain) the values that need to be configured on the AIM side (i.e.: Keycloak).

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this file meant to be in version control?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should be removed.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

With respect to a certified OpenID Connect library, that would be outside this scope. A UI with user authentication would certainly require this functionality and it would make sense to use a certified library.

This PR is only focused on authorization with ACA-Py as a resource server based on OAuth2. How the token is created and managed would depend on the specific use case - OIDC for a user facing UI, OAuth 2.0 client credentials grant for machine to machine etc.. In each case the appropriate OAuth2/OIDC library would be required to manage the token and authentication process.

Authlib is used in the openid4vc plugin as the core of the reference auth server. I could use some of the base Authlib Oauth2 functions in this PR but not sure if it is worth it. The code already uses PyJWT and PyJWKclient which is a common recommended approach.

A resource server is always going to require some trusted setup via manual configuration. A future PR could look at a more automatic approach if required along with granular scopes?

Responds to jamshale's suggestion to protect the OAuth feature with a
scenario test that runs in CI.

Adds scenarios/examples/oauth_resource_server/: a docker-compose stack
(Keycloak + ACA-Py in OAuth RS mode, askar single-wallet multitenant) and an
example.py that provisions a tenant wallet, wires its wallet_id into the
Keycloak tenant clients, then asserts the scope-enforcement matrix
(unauthenticated/invalid -> 401, admin scope -> 200, tenant scope confined to
tenant routes -> 403 on admin routes, acapy:wallet:create gating DID creation).
The harness auto-discovers the example, so no CI wiring is needed.

example.py polls Keycloak readiness itself (the Keycloak image ships no curl
for a container healthcheck), so ACA-Py only waits for the Keycloak container
to start. The realm export mirrors demo/demo-authserver; both READMEs now
cross-link and note to keep the two in sync.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Tim Bloomfield <tim.bloomfield@ontario.ca>
@timbl-ont

Copy link
Copy Markdown
Author

I'm also wondering that if instead of the demo we could move this to a scenario example with some basic tests scenarios/examples. Then we could have this feature protected during development PRs.

@jamshale I have added a scnenario with similar tests to the demo. I have left the demo in place but can also remove it if that this better?

jamshale
jamshale previously approved these changes Jul 15, 2026

@jamshale jamshale left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We can leave the demo. I think this is good 👍 . Thanks for adding the scenario test. I think this is the best way to protect features. Thanks for contributing.

@jamshale

jamshale commented Jul 15, 2026

Copy link
Copy Markdown
Contributor

We will need to update the branch and fix the small merge conflict.

timbl-ont and others added 2 commits July 16, 2026 23:42
Signed-off-by: Tim Bloomfield <tim.bloomfield@ontario.ca>

# Conflicts:
#	acapy_agent/admin/server.py
oauth-scope-test-report.md is output generated by
scripts/test-oauth-scopes.sh against a running demo stack, not source.
It does not belong in the repository.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: Tim Bloomfield <tim.bloomfield@ontario.ca>
@timbl-ont

Copy link
Copy Markdown
Author

@swcurran I think this PR is ready to go?

@swcurran

Copy link
Copy Markdown
Contributor

@timbl-ont --- awesome -- looks good. I'd like the reviewers (and you) to note that the SonarCloud issues are not a concern in a comment.

I'd also like to get the version right. I have not gotten to new version as planned, but can do that. Once we merge this, what version should it be -- 1.6.x or 1.7.0? And if 1.7.0, should we do a 1.6.1 ahead of merging?

How about a discussion at the ACA-Pug Meeting on Tuesday to set out the plan (and/or discuss here).

Congrats -- great work!!

@jamshale

Copy link
Copy Markdown
Contributor

Sonarcloud was a false positive.

@sonarqubecloud

Copy link
Copy Markdown

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

6 participants