Skip to content

[AAPRFE-955] feat(credentials): add OAuth2 client_credentials token plugin#185

Open
amasolov wants to merge 14 commits into
ansible:develfrom
amasolov:feat/oauth2-client-credentials-plugin
Open

[AAPRFE-955] feat(credentials): add OAuth2 client_credentials token plugin#185
amasolov wants to merge 14 commits into
ansible:develfrom
amasolov:feat/oauth2-client-credentials-plugin

Conversation

@amasolov
Copy link
Copy Markdown

@amasolov amasolov commented May 1, 2026

Resolves: AAPRFE-955

Summary

Add a new credential plugin (oauth2_client_credentials) that fetches a short-lived bearer token using the OAuth 2.0 client_credentials grant. This enables authentication with services that require OAuth2 tokens without storing long-lived credentials.

Closes #184

How it works

  1. An administrator creates an "OAuth2 Client Credentials Token Lookup" credential with:
    • Token Endpoint URL (e.g. https://login.microsoftonline.com/<tenant>/oauth2/v2.0/token)
    • Client ID (application/service principal identifier)
    • Client Secret
  2. A target credential (e.g. Source Control) links its password field to the OAuth2 credential and optionally specifies a scope (e.g. 499b84ac-1321-427f-aa17-267ca6975798/.default for Azure DevOps).
  3. On every credential resolution, the plugin POSTs to the token endpoint and returns the access_token from the response.

Works with any compliant OAuth2 provider including Microsoft Entra ID, Keycloak, Okta, and Auth0.

Changes

File Description
src/awx_plugins/credentials/oauth2_client_credentials.py New plugin module with backend function and plugin definition
tests/oauth2_client_credentials_test.py Unit tests covering success path, error handling, scope omission, multiple providers, and discarded kwargs
pyproject.toml Entry point under awx_plugins.credentials and optional dependency group credentials-oauth2-client-credentials
tests/importable_test.py Added to entry point smoke test tuple
docs/spelling_wordlist.txt Added "Entra" and "Keycloak"

Primary use case: Azure DevOps project sync

Azure DevOps has deprecated PATs and SSH keys for service-to-service authentication in favour of Microsoft Entra ID service principals. This plugin enables:

  1. Create an Entra ID App Registration with a client secret.
  2. Add the Service Principal to the ADO organisation.
  3. Create an OAuth2 credential with the Entra token endpoint, client ID, and secret.
  4. Link an SCM credential's password to the OAuth2 credential (username: x-token, scope: 499b84ac-1321-427f-aa17-267ca6975798/.default).
  5. Project sync automatically resolves a fresh Entra token on every run.

Testing

  • Unit tests follow the existing plugin test patterns (pytest + pytest-mock).
  • Manually verified end-to-end: OAuth2 credential -> linked SCM credential -> successful Azure DevOps project sync using an Entra ID service principal.

Signed-off-by: Alexey Masolov amasolov@redhat.com

Made with Cursor

Summary by CodeRabbit

  • New Features

    • Added an OAuth2 Client Credentials credential plugin to enable token-based authentication via the client credentials flow.
  • Tests

    • Added comprehensive tests for the new plugin covering metadata, request formation, error handling, timeouts/connection errors, and edge cases.
  • Documentation

    • Updated spelling wordlist and Sphinx config to reduce spurious nitpicky warnings.

Add a new credential plugin that fetches a short-lived bearer token
using the OAuth 2.0 client_credentials grant. This enables
authentication with services that require OAuth2 tokens, such as
Azure DevOps (via Microsoft Entra ID service principals), Keycloak,
Okta, and any compliant OAuth2 identity provider.

Closes ansible#184

Signed-off-by: Alexey Masolov <amasolov@redhat.com>
Signed-off-by: Alexey Masolov <alexey.masolov@gmail.com>
Made-with: Cursor
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 1, 2026

Warning

Rate limit exceeded

@amasolov has exceeded the limit for the number of commits that can be reviewed per hour. Please wait 35 minutes and 41 seconds before requesting another review.

To keep reviews running without waiting, you can enable usage-based add-on for your organization. This allows additional reviews beyond the hourly cap. Account admins can enable it under billing.

⌛ How to resolve this issue?

After the wait time has elapsed, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout.

Please see our FAQ for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Enterprise

Run ID: 872079d6-a800-4714-ba9e-aa661fb3e62a

📥 Commits

Reviewing files that changed from the base of the PR and between 3f44f95 and ff69ced.

📒 Files selected for processing (2)
  • src/awx_plugins/credentials/oauth2_client_credentials.py
  • tests/oauth2_client_credentials_test.py
📝 Walkthrough

Walkthrough

Adds a new OAuth2 Client Credentials credential plugin (implementation, entry point, optional dependency), tests, docs spelling updates, and a Sphinx nitpick ignore tweak.

Changes

Cohort / File(s) Summary
Packaging & Config
pyproject.toml, docs/conf.py
Register awx_plugins.credentials:oauth2_client_credentials entry point and add credentials-oauth2-client-credentials optional dependency; add requests.models.Response to Sphinx nitpick_ignore.
Spelling wordlist
docs/spelling_wordlist.txt
Added Entra, Keycloak, Okta, and synchronisation to approved spelling list.
Plugin Implementation
src/awx_plugins/credentials/oauth2_client_credentials.py
New credential plugin and backend: POSTs client_credentials grant to token_url (optional scope), maps network/timeouts to ValueError, handles non-200/invalid JSON, and returns access_token. Exposes inputs token_url, client_id, client_secret (secret), optional scope.
Tests
tests/oauth2_client_credentials_test.py, tests/importable_test.py
Add comprehensive unit tests covering metadata, request formation (including scope behavior), success and various error paths, exception mapping, and include new entry point in importable smoke tests.

Sequence Diagram(s)

sequenceDiagram
    participant Caller as AWX (credential resolver)
    participant Plugin as oauth2_client_credentials_plugin
    participant Backend as oauth2_client_credentials_backend
    participant Requests as requests.post
    participant Token as TokenEndpoint

    Caller->>Plugin: Resolve credential
    Plugin->>Backend: invoke backend(token_url, client_id, client_secret, scope)
    Backend->>Requests: POST token_url with grant_type=client_credentials, client_id, client_secret[, scope]
    Requests->>Token: HTTP request
    Token-->>Requests: 200 + JSON { access_token } / non-200 or invalid JSON
    Requests-->>Backend: HTTP response or raise Connection/Timeout error
    alt success with access_token
        Backend-->>Plugin: return access_token
        Plugin-->>Caller: provide access token
    else HTTP error / missing token / JSON error / network exception
        Backend-->>Plugin: raise ValueError with details
        Plugin-->>Caller: propagate error
    end
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The PR title clearly and specifically summarizes the main change: adding an OAuth2 client_credentials token plugin to the credentials system.
Linked Issues check ✅ Passed All coding objectives from issue #184 are met: plugin accepts token_url/client_id/client_secret/scope [#184], POSTs using client_credentials grant [#184], returns access_token [#184], supports multiple OAuth2 providers [#184], includes comprehensive tests and entry-point registration [#184].
Out of Scope Changes check ✅ Passed All changes are directly related to issue #184: new plugin implementation, comprehensive tests, entry-point registration, and minor documentation/spelling updates as specified in the issue requirements.
Docstring Coverage ✅ Passed Docstring coverage is 89.47% which is sufficient. The required threshold is 80.00%.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share
Review rate limit: 0/1 reviews remaining, refill in 35 minutes and 41 seconds.

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/awx_plugins/credentials/oauth2_client_credentials.py`:
- Around line 125-147: The code assumes resp.json() returns a mapping; defend
against non-object JSON by validating the parsed body is a dict before using
body.get(...) or indexing for ['access_token']. In the error path (where
resp.status_code != 200) call resp.json(), check isinstance(body, dict) and use
body.get('error_description', resp.text) only for dicts, otherwise set
error_detail to repr(body) or resp.text; similarly in the success path, parse =
resp.json(); if not isinstance(parse, dict) or 'access_token' not in parse,
raise the same ValueError with a clear message instead of letting
KeyError/TypeError leak (preserve the existing except-to-raise behavior around
access_token extraction).
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Enterprise

Run ID: c8f9b075-3af0-49d2-83fa-5d713a438229

📥 Commits

Reviewing files that changed from the base of the PR and between 3731d98 and 4caa758.

📒 Files selected for processing (5)
  • docs/spelling_wordlist.txt
  • pyproject.toml
  • src/awx_plugins/credentials/oauth2_client_credentials.py
  • tests/importable_test.py
  • tests/oauth2_client_credentials_test.py

Comment thread src/awx_plugins/credentials/oauth2_client_credentials.py Outdated
Guard resp.json() usage against token endpoints that return valid
JSON that is not an object (e.g. an array). Previously this would
raise an AttributeError or TypeError instead of the documented
ValueError.

Signed-off-by: Alexey Masolov <amasolov@redhat.com>
Signed-off-by: Alexey Masolov <alexey.masolov@gmail.com>
Made-with: Cursor
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

🧹 Nitpick comments (2)
tests/oauth2_client_credentials_test.py (2)

193-213: ⚡ Quick win

Assert the parametrized token_url is actually used

This test currently proves token parsing, but not provider URL forwarding. If backend URL handling regresses (e.g., hardcoded URL), this case can still pass.

Suggested patch
 def test_works_with_various_providers(
     mocker: MockerFixture,
     token_url: str,
 ) -> None:
     """Verify the plugin works with different OAuth2 provider URLs."""
-    mocker.patch.object(
+    mock_post = mocker.patch.object(
         oauth2_mod.requests,
         'post',
         return_value=_FakeResponse(
             status_code=200,
             json_data={'access_token': FAKE_TOKEN},
         ),
     )
@@
     token = oauth2_mod.oauth2_client_credentials_backend(
         token_url=token_url,
         client_id=CLIENT_ID,
         client_secret=CLIENT_SECRET,
     )

     assert token == FAKE_TOKEN
+    assert mock_post.call_args[0][0] == token_url

As per coding guidelines, "Focus on major issues impacting performance, readability, maintainability and security. Avoid nitpicks and avoid verbosity."

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/oauth2_client_credentials_test.py` around lines 193 - 213, The test
test_works_with_various_providers currently only validates token parsing; update
it to assert that oauth2_mod.requests.post was invoked with the parametrized
token_url so the backend actually forwards provider URLs. After patching
oauth2_mod.requests.post (the mock used in the test), add an assertion on that
mock (e.g., post_mock.assert_called_once_with(...) or check post_mock.call_args)
to verify the first positional argument equals token_url and that expected form
data/auth parameters used by oauth2_client_credentials_backend are present;
reference the test name test_works_with_various_providers and the function
oauth2_client_credentials_backend to locate where to add this assertion.

115-137: ⚡ Quick win

Lock in external-call contract assertions (timeout, URL, and client_secret)

Given this backend calls an external token endpoint, this test should also guard against accidental removal of timeout/auth payload forwarding.

Suggested patch
     call_kwargs = mock_post.call_args
+    assert call_kwargs[0][0] == TOKEN_URL
+    assert call_kwargs[1]['timeout'] == 30
     assert call_kwargs[1]['data']['grant_type'] == 'client_credentials'
     assert call_kwargs[1]['data']['client_id'] == CLIENT_ID
+    assert call_kwargs[1]['data']['client_secret'] == CLIENT_SECRET
     assert call_kwargs[1]['data']['scope'] == ADO_SCOPE

As per coding guidelines, "Focus on major issues impacting performance, readability, maintainability and security. Avoid nitpicks and avoid verbosity."

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/oauth2_client_credentials_test.py` around lines 115 - 137, The test
test_request_includes_grant_type is missing assertions that lock the
external-call contract (timeout, URL and client_secret); update the test that
mocks oauth2_mod.requests.post (mock_post) to also assert the request was made
to TOKEN_URL, that call_kwargs[1] includes the forwarded client_secret value
(CLIENT_SECRET) and that a timeout parameter is present and set (or at least
non-None) so oauth2_client_credentials_backend continues to pass through
timeout; locate these checks around the existing call_kwargs assertions and add
assertions for URL, client_secret and timeout to prevent regressions.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@tests/oauth2_client_credentials_test.py`:
- Around line 193-213: The test test_works_with_various_providers currently only
validates token parsing; update it to assert that oauth2_mod.requests.post was
invoked with the parametrized token_url so the backend actually forwards
provider URLs. After patching oauth2_mod.requests.post (the mock used in the
test), add an assertion on that mock (e.g.,
post_mock.assert_called_once_with(...) or check post_mock.call_args) to verify
the first positional argument equals token_url and that expected form data/auth
parameters used by oauth2_client_credentials_backend are present; reference the
test name test_works_with_various_providers and the function
oauth2_client_credentials_backend to locate where to add this assertion.
- Around line 115-137: The test test_request_includes_grant_type is missing
assertions that lock the external-call contract (timeout, URL and
client_secret); update the test that mocks oauth2_mod.requests.post (mock_post)
to also assert the request was made to TOKEN_URL, that call_kwargs[1] includes
the forwarded client_secret value (CLIENT_SECRET) and that a timeout parameter
is present and set (or at least non-None) so oauth2_client_credentials_backend
continues to pass through timeout; locate these checks around the existing
call_kwargs assertions and add assertions for URL, client_secret and timeout to
prevent regressions.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Enterprise

Run ID: 0e691348-15cc-44d3-bf89-e10bf100e018

📥 Commits

Reviewing files that changed from the base of the PR and between 4caa758 and 856d793.

📒 Files selected for processing (2)
  • src/awx_plugins/credentials/oauth2_client_credentials.py
  • tests/oauth2_client_credentials_test.py
🚧 Files skipped from review as they are similar to previous changes (1)
  • src/awx_plugins/credentials/oauth2_client_credentials.py

Extract _post_token_request, _extract_error_detail, and
_extract_access_token helpers to reduce cognitive complexity, raise
count, and try-body length. Replace magic number 30 with a named
constant. Use http.HTTPStatus in tests to avoid WPS432 on status
codes. Widen _FakeResponse.json_data to object to allow non-dict
payloads in tests. Add noqa: ANN101 for self parameter annotations.

Signed-off-by: Alexey Masolov <amasolov@redhat.com>
Signed-off-by: Alexey Masolov <alexey.masolov@gmail.com>
Made-with: Cursor
@amasolov amasolov force-pushed the feat/oauth2-client-credentials-plugin branch from ca44827 to 877d8d2 Compare May 1, 2026 04:41
pre-commit-ci Bot and others added 2 commits May 1, 2026 04:44
Annotate resp.json() return as object to satisfy mypy misc check.
Move noqa: ANN101 to the self parameter line where flake8 reports it.

Signed-off-by: Alexey Masolov <amasolov@redhat.com>
Signed-off-by: Alexey Masolov <alexey.masolov@gmail.com>
Made-with: Cursor
@codecov
Copy link
Copy Markdown

codecov Bot commented May 1, 2026

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 78.62%. Comparing base (3731d98) to head (ff69ced).
✅ All tests successful. No failed tests found.

amasolov added 3 commits May 1, 2026 14:54
Add Okta and synchronisation to the spelling wordlist. Suppress
Sphinx nitpick warning for requests.models.Response which is not
in the intersphinx inventory.

Signed-off-by: Alexey Masolov <amasolov@redhat.com>
Signed-off-by: Alexey Masolov <alexey.masolov@gmail.com>
Made-with: Cursor
Add test for the case where the token endpoint returns HTTP 200 but
the response body is not valid JSON, exercising the ValueError
re-raise in _extract_access_token.

Signed-off-by: Alexey Masolov <amasolov@redhat.com>
Signed-off-by: Alexey Masolov <alexey.masolov@gmail.com>
Made-with: Cursor
Replace repeated oauth2_client_credentials_backend call arguments
with a shared _COMMON_BACKEND_KWARGS dict to stay within the
overused-expression limit.

Signed-off-by: Alexey Masolov <amasolov@redhat.com>
Signed-off-by: Alexey Masolov <alexey.masolov@gmail.com>
Made-with: Cursor
@amasolov amasolov changed the title feat(credentials): add OAuth2 client_credentials token plugin [AAPRFE-955] feat(credentials): add OAuth2 client_credentials token plugin May 1, 2026
amasolov added a commit to amasolov/eda-server that referenced this pull request May 1, 2026
Add a new managed credential type that fetches OAuth2 access tokens
using the client_credentials grant. This enables external credential
lookups against any OAuth2 provider (Microsoft Entra/Azure AD, Keycloak,
Okta, etc.) for use in rulebook activations and other EDA workflows.

Changes:
- Register oauth2_client_credentials_plugin in PLUGIN_TYPES
- Add DefaultCredentialType.OAUTH2_CLIENT_CREDENTIALS enum value
- Define OAUTH2_CLIENT_CREDENTIALS_INPUTS schema and seed via
  create_initial_data management command
- Add credentials-oauth2-client-credentials extra to awx-plugins-core
  dependency
- Add unit tests for plugin registration, dispatch, and initial data
  seeding

Depends-On: ansible/awx-plugins#185

Signed-off-by: Alexey Masolov <amasolov@redhat.com>
Made-with: Cursor
amasolov and others added 3 commits May 1, 2026 15:18
Replace the mutable module-level dict constant with a pytest fixture
that wraps the backend call, and consolidate error-path test cases
into parametrized groups.

Signed-off-by: Alexey Masolov <amasolov@redhat.com>
Signed-off-by: Alexey Masolov <alexey.masolov@gmail.com>
Made-with: Cursor
…licit-any

Use typing.Protocol for the fixture type to avoid Callable[..., str]
which triggers mypy explicit-any. Bundle error test params into a
NamedTuple to stay within the 5-argument limit. Flip negated condition
to satisfy WPS504.

Signed-off-by: Alexey Masolov <amasolov@redhat.com>
Signed-off-by: Alexey Masolov <alexey.masolov@gmail.com>
Made-with: Cursor
Signed-off-by: Alexey Masolov <alexey.masolov@gmail.com>
Made-with: Cursor
@amasolov amasolov force-pushed the feat/oauth2-client-credentials-plugin branch from 3f44f95 to 0c2e324 Compare May 1, 2026 05:36
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/awx_plugins/credentials/oauth2_client_credentials.py`:
- Around line 112-125: The current try/except around the requests.post call only
handles Timeout and ConnectionError but misses other transport failures; add a
final except requests.exceptions.RequestException as req_exc clause after the
existing handlers to normalize all remaining requests transport errors into a
ValueError (e.g., raise ValueError(f'Error requesting token from {token_url!s}')
from req_exc), keeping token_url, post_data and _REQUEST_TIMEOUT_SECONDS as used
in the existing requests.post call so every RequestException is wrapped and
propagated as a ValueError.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Enterprise

Run ID: 59dbf049-9c43-4935-a9a6-3ad2101adee6

📥 Commits

Reviewing files that changed from the base of the PR and between 856d793 and 3f44f95.

📒 Files selected for processing (4)
  • docs/conf.py
  • docs/spelling_wordlist.txt
  • src/awx_plugins/credentials/oauth2_client_credentials.py
  • tests/oauth2_client_credentials_test.py
✅ Files skipped from review due to trivial changes (1)
  • docs/conf.py
🚧 Files skipped from review as they are similar to previous changes (2)
  • docs/spelling_wordlist.txt
  • tests/oauth2_client_credentials_test.py

Comment thread src/awx_plugins/credentials/oauth2_client_credentials.py
amasolov added 2 commits May 1, 2026 15:44
Add a catch-all handler for requests.exceptions.RequestException after
the specific Timeout and ConnectionError handlers to normalise all
transport failures (e.g. TooManyRedirects, InvalidURL) to ValueError,
matching the documented error contract.

Signed-off-by: Alexey Masolov <amasolov@redhat.com>
Signed-off-by: Alexey Masolov <alexey.masolov@gmail.com>
Made-with: Cursor
Verify that test_works_with_various_providers checks the parametrized
token_url is actually passed to requests.post, not just that a token
is returned.

Signed-off-by: Alexey Masolov <amasolov@redhat.com>
Signed-off-by: Alexey Masolov <alexey.masolov@gmail.com>
Made-with: Cursor
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.

Add OAuth2 Client Credentials Token credential plugin

1 participant