Skip to content

Add OAuth2 for ticket validation API - #15

Merged
jbsilva merged 2 commits into
mainfrom
auth
Jul 12, 2026
Merged

jbsilva merged 2 commits into
mainfrom
auth

Conversation

@jbsilva

@jbsilva jbsilva commented Apr 6, 2026

Copy link
Copy Markdown
Member

The validation API requires authentication now.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

Adds optional OAuth2 (client credentials) authentication support to the ticket validation/refresh API calls, with token caching and new tests/docs to support the rollout of an authenticated validation endpoint.

Changes:

  • Add OAuth2 client-credentials token fetching + caching and include Authorization: Bearer … on ticket API calls when configured.
  • Skip ticket refresh/validation calls when OAuth2 is configured but a token cannot be obtained.
  • Add pytest coverage for token caching and header injection, plus document new .secrets variables.

Reviewed changes

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

Show a summary per file
File Description
discord_bot/helpers/ticket_connector.py Implements OAuth2 token retrieval/caching and applies bearer auth headers to ticket API requests.
discord_bot/configuration.py Adds optional OAuth2 settings to the config loader.
discord_bot/config.toml Introduces optional OAuth2 config keys (disabled by default).
tests/test_ticket_connector_oauth2.py Adds async tests for OAuth2 token caching and request header behavior.
README.md Documents the new optional OAuth2 environment variables in .secrets.

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

Comment on lines +46 to +48
self._oauth2_token: str | None = None
self._oauth2_token_expires_at: float = 0.0
self._oauth2_token_lock = asyncio.Lock()

Copilot AI Apr 6, 2026

Copy link

Choose a reason for hiding this comment

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

TicketOrder is instantiated at import time in discord_bot/bot.py and discord_bot/cogs/registration.py. Creating asyncio.Lock() in __init__ can bind the lock to a different (non-running) event loop than the one used by asyncio.run()/discord, which can later raise "bound to a different event loop" errors when acquiring the lock. Consider lazily initializing the lock inside _get_oauth2_token (when a running loop exists) or otherwise ensuring the lock is created in the same running loop that will use it.

Copilot uses AI. Check for mistakes.
Comment on lines +102 to +110
data = await response.json()
except (aiohttp.ClientError, ValueError):
_logger.exception("Error occurred while fetching OAuth2 token from %r", self.TICKETS_OAUTH2_TOKEN_URL)
return None

access_token = data.get("access_token")
if not access_token:
_logger.error("OAuth2 token response does not contain an access token")
return None

Copilot AI Apr 6, 2026

Copy link

Choose a reason for hiding this comment

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

_fetch_oauth2_token assumes await response.json() returns a dict and immediately calls data.get(...). If the IdP returns valid JSON that isn't an object (e.g., a list or string), this will raise AttributeError and bypass the current exception handling. Add an explicit isinstance(data, dict) check (and log/return None if not) before accessing .get.

Copilot uses AI. Check for mistakes.
Comment on lines 147 to +152
async def _update_tickets(self, url: str) -> bool:
async with aiohttp.ClientSession() as session, session.get(url, headers=self.HEADERS) as response:
headers = await self._build_headers()
if self._oauth2_is_enabled() and "Authorization" not in headers:
_logger.error("Skipping ticket refresh because OAuth2 token could not be obtained")
return False

Copilot AI Apr 6, 2026

Copy link

Choose a reason for hiding this comment

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

New behavior in _update_tickets skips refresh calls when OAuth2 is enabled but the token cannot be obtained. There are tests for get_ticket_type OAuth2 behavior, but no test covering this refresh-path skip; consider adding a similar test asserting aiohttp.ClientSession is not called (or that _update_tickets returns False) when _get_oauth2_token returns None.

Copilot uses AI. Check for mistakes.
Comment thread README.md Outdated
# Optional: required when ticket validation API enforces OAuth2
TICKETS_OAUTH2_CLIENT_ID=<OAuth2ClientId>
TICKETS_OAUTH2_CLIENT_SECRET=<OAuth2ClientSecret>
TICKETS_OAUTH2_TOKEN_URL=<https://your-idp.example.com/realms/<realm>/protocol/openid-connect/token>

Copilot AI Apr 6, 2026

Copy link

Choose a reason for hiding this comment

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

The .secrets example for TICKETS_OAUTH2_TOKEN_URL uses nested angle brackets (<https://.../<realm>/...>), which is hard to copy/paste and ambiguous as a placeholder. Consider using a plain URL with a single placeholder style (e.g., .../realms/{realm}/...) and avoid wrapping the whole value in <...>.

Suggested change
TICKETS_OAUTH2_TOKEN_URL=<https://your-idp.example.com/realms/<realm>/protocol/openid-connect/token>
TICKETS_OAUTH2_TOKEN_URL=https://your-idp.example.com/realms/{realm}/protocol/openid-connect/token

Copilot uses AI. Check for mistakes.
- Reject non-object JSON token responses instead of raising AttributeError
- Add tests for the refresh-path auth skip, bearer header on refresh, and
  non-dict token responses
- Use an unambiguous, copy-pasteable OAuth2 token URL example in README
@jbsilva

jbsilva commented Jul 12, 2026

Copy link
Copy Markdown
Member Author

Reviewed against the actual validation API (fact_check_in, which validates Keycloak JWT Bearer tokens via JWKS). The client-credentials grant here produces a Bearer token that matches what the API expects, so the approach is sound. Addressed the Copilot feedback in 83d5dd0:

  1. asyncio.Lock() bound to a different event loop — Not an issue on this project. It pins Python 3.12, and since 3.10 asyncio.Lock() no longer binds a loop at construction; it binds lazily on first await. The bot runs a single event loop, so no fix needed.
  2. response.json() may not be a dict — Fixed. Added an isinstance(data, dict) guard so a non-object JSON body is logged and rejected instead of raising AttributeError.
  3. No test for the refresh-path skip — Added. New tests cover _update_tickets skipping the call (and returning False) when no token is available, plus the bearer header being sent on refresh.
  4. README nested angle brackets in token URL — Fixed. Replaced with a plain, copy-pasteable example URL.

Also added a test for the non-dict token response. Full suite is green (82 passed) and ruff check . is clean.

Operational note (Keycloak): the API validates the token aud against OIDC_AUDIENCE. Keycloak client-credentials tokens don't include the client ID in aud by default — make sure the client has an audience mapper (or that OIDC_AUDIENCE matches what's actually in the issued token), otherwise requests will 401 with "Invalid token audience".

@jbsilva
jbsilva merged commit ff88ab2 into main Jul 12, 2026
2 checks passed
@jbsilva
jbsilva deleted the auth branch July 12, 2026 15:49
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.

2 participants