Skip to content

Conversation

@DrJfrost
Copy link
Contributor

@DrJfrost DrJfrost commented Nov 13, 2025

Ticket ENG-1952 (https://ethyca.atlassian.net/browse/ENG-1952)

Description Of Changes

Introduced a dedicated OktaOAuth2Client helper that encapsulates the OAuth2 Client Credentials flow for the Okta discovery worker. The helper validates required secrets, reuses cached tokens when still valid, refreshes tokens proactively, and persists refreshed values back into the ConnectionConfig. Added unit tests covering cached-token reuse, token refresh, error handling, and missing-secret scenarios.

Code Changes

  • created OktaOAuth2Client with token acquisition, caching, and persistence logic under src/fides/api/service/connectors/okta_oauth2_client.py

  • added pytest coverage for the helper in tests/ops/service/connectors/test_okta_oauth2_client.py using a minimal DummyConnectionConfig

Steps to Confirm

  1. python -m pytest fides/tests/ops/service/connectors/test_okta_oauth2_client.py

Pre-Merge Checklist

  • Issue requirements met
  • All CI pipelines succeeded
  • CHANGELOG.md updated
    • Add a db-migration This indicates that a change includes a database migration label to the entry if your change includes a DB migration
    • Add a high-risk This issue suggests changes that have a high-probability of breaking existing code label to the entry if your change includes a high-risk change (i.e. potential for performance impact or unexpected regression) that should be flagged
    • Updates unreleased work already in Changelog, no new entry necessary
  • UX feedback:
    • All UX related changes have been reviewed by a designer
    • No UX review needed
  • Followup issues:
    • Followup issues created
    • No followup issues
  • Database migrations:
    • Ensure that your downrev is up to date with the latest revision on main
    • Ensure that your downgrade() migration is correct and works
      • If a downgrade migration is not possible for this change, please call this out in the PR description!
    • No migrations
  • Documentation:
    • Documentation complete, PR opened in fidesdocs
    • Documentation issue created in fidesdocs
    • If there are any new client scopes created as part of the pull request, remember to update public-facing documentation that references our scope registry
    • No documentation updates required

@DrJfrost DrJfrost requested a review from a team as a code owner November 13, 2025 18:38
@DrJfrost DrJfrost requested review from galvana and removed request for a team November 13, 2025 18:38
@vercel
Copy link

vercel bot commented Nov 13, 2025

The latest updates on your projects. Learn more about Vercel for GitHub.

2 Skipped Deployments
Project Deployment Preview Comments Updated (UTC)
fides-plus-nightly Ignored Ignored Preview Nov 19, 2025 5:47pm
fides-privacy-center Ignored Ignored Nov 19, 2025 5:47pm

@greptile-apps
Copy link
Contributor

greptile-apps bot commented Nov 13, 2025

Greptile Overview

Greptile Summary

This PR introduces a new OktaOAuth2Client helper class that implements OAuth2 client credentials flow with token caching and automatic refresh logic. The implementation includes comprehensive unit tests covering token lifecycle, error handling, and validation.

Critical Issue:

  • The OktaOAuth2Client is not integrated with the existing OktaConnector. The connector's _get_oauth_access_token() method still expects a pre-existing access_token in secrets but doesn't use this new client to fetch or refresh tokens. Without integration, the OAuth2 client credentials flow won't actually work.

Minor Issues:

  • Uses deprecated datetime.utcnow() (deprecated in Python 3.12+)
  • _validate_required_secrets() could be a static method per coding standards

Confidence Score: 1/5

  • This PR cannot be safely merged as the OAuth2 client is not integrated and won't function
  • The implementation creates an OAuth2 client helper but fails to integrate it with the OktaConnector, meaning the OAuth2 client credentials flow described in the PR won't actually work. The OktaConnector still expects a manually provided access_token rather than using this client to fetch/refresh tokens.
  • src/fides/api/service/connectors/okta_connector.py needs to be updated to use OktaOAuth2Client

Important Files Changed

File Analysis

Filename Score Overview
src/fides/api/service/connectors/okta_oauth2_client.py 2/5 Implements OAuth2 client credentials helper but is not integrated with OktaConnector - won't work without integration
tests/ops/service/connectors/test_okta_oauth2_client.py 4/5 Comprehensive unit tests covering token caching, refresh, error handling, and missing secrets scenarios

Copy link
Contributor

@greptile-apps greptile-apps bot left a comment

Choose a reason for hiding this comment

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

2 files reviewed, 3 comments

Edit Code Review Agent Settings | Greptile

Comment on lines 1 to 150
from __future__ import annotations

from datetime import datetime, timedelta
from typing import Callable, Dict, Optional

import requests
from loguru import logger
from requests.auth import HTTPBasicAuth
from sqlalchemy.orm import Session

from fides.api.common_exceptions import ConnectionException, OAuth2TokenException
from fides.api.models.connectionconfig import ConnectionConfig


class OktaOAuth2Client:
"""
Lightweight OAuth2 Client Credentials helper for Okta.

This class encapsulates token acquisition, caching, and persistence in the
`ConnectionConfig` secrets for the Okta connector/worker use case.
"""

TOKEN_PATH = "/oauth2/v1/token"
DEFAULT_SCOPE = "okta.apps.read"
DEFAULT_EXPIRATION_SECONDS = 3600
EXPIRATION_BUFFER_SECONDS = 300

def __init__(
self,
connection_config: ConnectionConfig,
*,
scope: Optional[str] = None,
clock: Callable[[], datetime] = datetime.utcnow,
) -> None:
self.connection_config = connection_config
self.scope = scope or self.DEFAULT_SCOPE
self.clock = clock

def get_access_token(self, db: Optional[Session] = None) -> str:
"""
Retrieve a valid OAuth2 access token for Okta.
Returns a cached token when still valid or requests a new one as needed.
"""
self._validate_required_secrets()

secrets = self.connection_config.secrets or {}
access_token: Optional[str] = secrets.get("access_token")
expires_at: Optional[int] = secrets.get("expires_at")

if access_token and self._token_is_valid(expires_at):
return access_token

token_response = self._request_new_token()
return self._persist_token(token_response, db)

def _validate_required_secrets(self) -> None:
secrets = self.connection_config.secrets or {}
missing = [
secret_name
for secret_name in ("org_url", "client_id", "client_secret")
if not secrets.get(secret_name)
]
if missing:
raise ConnectionException(
"Okta connection configuration is missing required OAuth2 secrets: "
+ ", ".join(missing)
)

def _token_is_valid(self, expires_at: Optional[int]) -> bool:
if not expires_at:
return False

buffer_time = self.clock() + timedelta(seconds=self.EXPIRATION_BUFFER_SECONDS)
return expires_at > int(buffer_time.timestamp())

def _token_endpoint(self) -> str:
org_url: str = self.connection_config.secrets.get("org_url") # type: ignore
# Ensure only a single slash between org_url and the token path
return org_url.rstrip("/") + self.TOKEN_PATH

def _request_new_token(self) -> Dict[str, str]:
secrets = self.connection_config.secrets or {}
client_id: str = secrets.get("client_id") # type: ignore
client_secret: str = secrets.get("client_secret") # type: ignore

data = {
"grant_type": "client_credentials",
"scope": self.scope,
}

try:
response = requests.post(
self._token_endpoint(),
data=data,
headers={"Content-Type": "application/x-www-form-urlencoded"},
auth=HTTPBasicAuth(client_id, client_secret),
timeout=30,
)
except requests.exceptions.RequestException as exc: # pragma: no cover
logger.error("Error requesting Okta OAuth2 access token: {}", exc)
raise OAuth2TokenException(
f"Error occurred during the access token request for {self.connection_config.key}: {exc}"
) from exc

if response.status_code != 200:
logger.error(
"Okta OAuth2 token request failed with status {}: {}",
response.status_code,
response.text,
)
raise OAuth2TokenException(
f"Unable to retrieve token for {self.connection_config.key}. "
f"Status code: {response.status_code}"
)

try:
return response.json()
except ValueError as exc:
logger.error("Invalid JSON in Okta OAuth2 token response: {}", exc)
raise OAuth2TokenException(
f"Invalid JSON response retrieving token for {self.connection_config.key}: {exc}"
) from exc

def _persist_token(
self, token_response: Dict[str, str], db: Optional[Session]
) -> str:
access_token = token_response.get("access_token")
if not access_token:
logger.error("Okta OAuth2 token response missing access_token: {}", token_response)
raise OAuth2TokenException(
f"Unable to retrieve token for {self.connection_config.key} (missing access_token)."
)

expires_in = token_response.get("expires_in") or self.DEFAULT_EXPIRATION_SECONDS
expires_at = int(self.clock().timestamp()) + int(expires_in)

updated_secrets = {
**(self.connection_config.secrets or {}),
"access_token": access_token,
"expires_at": expires_at,
}

db = db or Session.object_session(self.connection_config)
if db is None:
logger.warning(
"Okta OAuth2 token persistence without DB session for {}", self.connection_config.key
)

self.connection_config.update(db, data={"secrets": updated_secrets})
return access_token
Copy link
Contributor

Choose a reason for hiding this comment

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

logic: OktaOAuth2Client is created but never used anywhere in the codebase. The OktaConnector._get_oauth_access_token() still expects a pre-existing access_token in secrets, but doesn't use this client to fetch or refresh tokens. This implementation won't actually enable OAuth2 client credentials flow unless integrated.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

This is a base structure that will be used later for the OAuth2 integration with Okta.

connection_config: ConnectionConfig,
*,
scope: Optional[str] = None,
clock: Callable[[], datetime] = datetime.utcnow,
Copy link
Contributor

Choose a reason for hiding this comment

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

style: datetime.utcnow() is deprecated in Python 3.12+. While the codebase uses it extensively, consider using datetime.now(timezone.utc) for new code to avoid future deprecation warnings.

Comment on lines 56 to 67
def _validate_required_secrets(self) -> None:
secrets = self.connection_config.secrets or {}
missing = [
secret_name
for secret_name in ("org_url", "client_id", "client_secret")
if not secrets.get(secret_name)
]
if missing:
raise ConnectionException(
"Okta connection configuration is missing required OAuth2 secrets: "
+ ", ".join(missing)
)
Copy link
Contributor

Choose a reason for hiding this comment

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

style: consider making this a static method since it doesn't use instance state beyond what's passed as parameters

Context Used: Rule from dashboard - Use static methods instead of instance methods when the method doesn't require instance state. (source)

@codecov
Copy link

codecov bot commented Nov 13, 2025

Codecov Report

❌ Patch coverage is 91.78082% with 6 lines in your changes missing coverage. Please review.
✅ Project coverage is 82.08%. Comparing base (cae10b3) to head (1047ca2).
⚠️ Report is 2 commits behind head on ENG-1916.

Files with missing lines Patch % Lines
...fides/api/service/connectors/okta_oauth2_client.py 91.66% 4 Missing and 2 partials ⚠️
Additional details and impacted files
@@             Coverage Diff              @@
##           ENG-1916    #6963      +/-   ##
============================================
+ Coverage     82.07%   82.08%   +0.01%     
============================================
  Files           525      526       +1     
  Lines         34446    34511      +65     
  Branches       3966     3970       +4     
============================================
+ Hits          28270    28330      +60     
- Misses         5355     5359       +4     
- Partials        821      822       +1     

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@galvana galvana removed their request for review November 15, 2025 04:02
@stephenkiers stephenkiers marked this pull request as draft November 25, 2025 17:05
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