Skip to content

Commit 5d1ea7a

Browse files
committed
ENG-1952 Add OAuth2 HTTP client with DPoP support
1 parent 65e026e commit 5d1ea7a

File tree

3 files changed

+580
-0
lines changed

3 files changed

+580
-0
lines changed

requirements.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ python-jose[cryptography]==3.3.0
5757
pyyaml==6.0.1
5858
pyahocorasick==2.1.0
5959
redis==3.5.3
60+
requests-oauth2client>=1.5.0
6061
requests-oauthlib==2.0.0
6162
rich-click==1.6.1
6263
sendgrid==6.9.7
Lines changed: 208 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,208 @@
1+
import json
2+
import re
3+
from typing import TYPE_CHECKING, Any, Dict, List, Optional, Tuple
4+
5+
import requests
6+
from requests.auth import AuthBase
7+
8+
from fides.api.common_exceptions import ConnectionException
9+
10+
if TYPE_CHECKING:
11+
from requests_oauth2client import DPoPKey, OAuth2Client
12+
13+
14+
# --- Constants ---
15+
DEFAULT_OKTA_SCOPES = ("okta.apps.read",)
16+
DEFAULT_API_LIMIT = 200
17+
DEFAULT_MAX_PAGES = 100
18+
DEFAULT_REQUEST_TIMEOUT = 30
19+
20+
# Mapping from EC curve to JWT signing algorithm
21+
EC_CURVE_ALG_MAP = {
22+
"P-256": "ES256",
23+
"P-384": "ES384",
24+
"P-521": "ES512",
25+
}
26+
27+
28+
class OktaHttpClient:
29+
"""
30+
Minimal HTTP client for Okta API with OAuth2 private_key_jwt + DPoP.
31+
32+
Why not Okta SDK? SDK lacks DPoP support (affects 30-50% of Okta orgs).
33+
34+
Deliberately scoped: This client lives in connectors/, not in the SaaS framework.
35+
If we later need a generic private_key_jwt + DPoP auth strategy for SaaS connectors,
36+
we will extract it from this iplementation with a clear product decision and 2-3 use
37+
cases validating the abstraction.
38+
"""
39+
40+
def __init__(
41+
self,
42+
org_url: str,
43+
client_id: str,
44+
private_key: str,
45+
scopes: Optional[List[str]] = None,
46+
*,
47+
oauth_client: "Optional[OAuth2Client]" = None, # For test injection
48+
dpop_key: "Optional[DPoPKey]" = None, # For test injection
49+
):
50+
"""
51+
Initialize OktaHttpClient.
52+
53+
Args:
54+
org_url: Okta organization URL (e.g., https://your-org.okta.com)
55+
client_id: OAuth2 client ID
56+
private_key: Private key in JWK (JSON) format for client authentication (provided by Okta)
57+
scopes: OAuth2 scopes
58+
oauth_client: For test injection - pre-configured OAuth2Client
59+
dpop_key: For test injection - pre-configured DPoP key
60+
"""
61+
self.org_url = org_url.rstrip("/")
62+
self.scopes = tuple(scopes) if scopes is not None else DEFAULT_OKTA_SCOPES
63+
64+
if oauth_client is not None or dpop_key is not None:
65+
if oauth_client is None or dpop_key is None:
66+
raise ValueError(
67+
"Both oauth_client and dpop_key must be provided when injecting test dependencies."
68+
)
69+
self._oauth_client = oauth_client
70+
self._dpop_key = dpop_key
71+
return
72+
73+
try:
74+
from requests_oauth2client import DPoPKey, OAuth2Client, PrivateKeyJwt
75+
76+
private_jwk = self._parse_jwk(private_key)
77+
alg = self._determine_alg_from_jwk(private_jwk)
78+
79+
# Auto-generate DPoP key (EC P-256 for performance)
80+
# Okta requires DPoP key to be SEPARATE from client auth key
81+
self._dpop_key = DPoPKey.generate(alg="ES256")
82+
83+
self._oauth_client = OAuth2Client(
84+
token_endpoint=f"{self.org_url}/oauth2/v1/token",
85+
auth=PrivateKeyJwt(client_id, private_jwk, alg=alg),
86+
)
87+
except ImportError as e:
88+
raise ConnectionException(
89+
"The 'requests-oauth2client' library is required for Okta connector. "
90+
"Please install it with: pip install requests-oauth2client"
91+
) from e
92+
except (ValueError, TypeError) as e:
93+
raise ConnectionException(f"Invalid key format: {str(e)}") from e
94+
95+
@staticmethod
96+
def _parse_jwk(private_key: str) -> Dict[str, Any]:
97+
"""Parse and validate a private key in JWK format."""
98+
try:
99+
jwk_dict = json.loads(private_key.strip())
100+
except json.JSONDecodeError as exc:
101+
raise ValueError("Private key must be in JWK (JSON) format.") from exc
102+
103+
if "d" not in jwk_dict:
104+
raise ValueError("JWK is not a private key (missing 'd' parameter).")
105+
106+
return jwk_dict
107+
108+
@staticmethod
109+
def _determine_alg_from_jwk(jwk: Dict[str, Any]) -> str:
110+
"""Determine the signing algorithm from the JWK."""
111+
if "alg" in jwk:
112+
return jwk["alg"]
113+
114+
kty = jwk.get("kty")
115+
if kty == "RSA":
116+
return "RS256"
117+
if kty == "EC":
118+
crv = jwk.get("crv", "P-256")
119+
return EC_CURVE_ALG_MAP.get(crv, "ES256")
120+
121+
return "RS256" # Default fallback
122+
123+
def _get_token(self) -> AuthBase:
124+
"""Get DPoP-bound access token."""
125+
try:
126+
from requests_oauth2client.exceptions import OAuth2Error
127+
128+
return self._oauth_client.client_credentials(
129+
scope=" ".join(self.scopes), dpop_key=self._dpop_key
130+
)
131+
except ImportError as e:
132+
raise ConnectionException(
133+
"The 'requests-oauth2client' library is required for Okta connector. "
134+
"Please install it with: pip install requests-oauth2client"
135+
) from e
136+
except OAuth2Error as e:
137+
raise ConnectionException(
138+
f"OAuth2 token acquisition failed: {str(e)}"
139+
) from e
140+
141+
def list_applications(
142+
self, limit: int = DEFAULT_API_LIMIT, after: Optional[str] = None
143+
) -> Tuple[List[Dict[str, Any]], Optional[str]]:
144+
"""
145+
List Okta applications with cursor-based pagination.
146+
147+
Args:
148+
limit: Maximum number of applications to return
149+
after: Cursor for next page (from previous response)
150+
151+
Returns:
152+
Tuple of (apps_list, next_cursor). next_cursor is None if no more pages.
153+
"""
154+
token = self._get_token()
155+
params: Dict[str, Any] = {"limit": limit}
156+
if after:
157+
params["after"] = after
158+
159+
try:
160+
response = requests.get(
161+
f"{self.org_url}/api/v1/apps",
162+
params=params,
163+
auth=token,
164+
timeout=DEFAULT_REQUEST_TIMEOUT,
165+
)
166+
response.raise_for_status()
167+
except requests.RequestException as e:
168+
raise ConnectionException(f"Okta API request failed: {str(e)}") from e
169+
170+
next_cursor = self._extract_next_cursor(response.headers.get("Link"))
171+
return response.json(), next_cursor
172+
173+
def list_all_applications(
174+
self, page_size: int = DEFAULT_API_LIMIT, max_pages: int = DEFAULT_MAX_PAGES
175+
) -> List[Dict[str, Any]]:
176+
"""
177+
Fetch all Okta applications with automatic pagination.
178+
179+
Args:
180+
page_size: Number of applications per page
181+
max_pages: Maximum number of pages to fetch (safety limit)
182+
183+
Returns:
184+
List of all applications
185+
"""
186+
all_apps: List[Dict[str, Any]] = []
187+
cursor: Optional[str] = None
188+
for _ in range(max_pages):
189+
apps, next_cursor = self.list_applications(limit=page_size, after=cursor)
190+
all_apps.extend(apps)
191+
192+
if not next_cursor:
193+
break
194+
cursor = next_cursor
195+
196+
return all_apps
197+
198+
@staticmethod
199+
def _extract_next_cursor(link_header: Optional[str]) -> Optional[str]:
200+
"""Extract 'after' cursor from Okta Link header."""
201+
if not link_header:
202+
return None
203+
for link in link_header.split(","):
204+
if 'rel="next"' in link:
205+
match = re.search(r"after=([^&>]+)", link)
206+
if match:
207+
return match.group(1)
208+
return None

0 commit comments

Comments
 (0)