cradle.sdk.auth.m2m
1import json 2import time 3from collections.abc import Generator 4from datetime import UTC, datetime, timedelta 5from pathlib import Path 6 7import httpx 8import jwt 9from pydantic import BaseModel 10 11 12class ClientAuthError(Exception): 13 def __init__(self, message: str): 14 self.message = message 15 super().__init__(f"M2M Authentication Error: {message}") 16 17 18class M2MTokenResponse(BaseModel): 19 access_token: str 20 token_type: str = "Bearer" 21 expires_in: int 22 refresh_token: str | None = None 23 scope: str | None = None 24 25 26class M2MAuth(httpx.Auth): 27 """WorkOS Machine-to-Machine authentication using OAuth2 client credentials flow.""" 28 29 def __init__( 30 self, 31 client_id: str, 32 client_secret: str, 33 auth_api_url: str | None = None, 34 scope: str = "openid profile", 35 ): 36 self.client_id = client_id 37 self.client_secret = client_secret 38 self.auth_api_url = auth_api_url.rstrip("/") if auth_api_url is not None else None 39 self.scope = scope 40 self.token: M2MTokenResponse | None = None 41 self._token_acquired_at: float | None = None 42 43 @staticmethod 44 def from_json_file(file_path: Path) -> "M2MAuth": 45 json_data = json.loads(file_path.read_text("utf-8")) 46 return M2MAuth.from_json(json_data) 47 48 @staticmethod 49 def from_json(json: dict) -> "M2MAuth": 50 client_id = json.get("client_id") 51 client_secret = json.get("client_secret") 52 if not client_id or not client_secret: 53 raise ClientAuthError("Client ID or client secret is not set") 54 return M2MAuth(client_id=client_id, client_secret=client_secret) 55 56 @property 57 def access_token(self) -> str | None: 58 return self.token.access_token if self.token else None 59 60 @property 61 def refresh_token(self) -> str | None: 62 return self.token.refresh_token if self.token else None 63 64 @property 65 def expires_at(self) -> datetime | None: 66 if self.token is None or self._token_acquired_at is None: 67 return None 68 # Per https://github.com/jpadilla/pyjwt/issues/939 - we should not be verifying the `iat` time here 69 # as it could be slightly in the future. 70 jwt_decoded = jwt.decode(self.token.access_token, options={"verify_signature": False, "verify_iat": False}) 71 return datetime.fromtimestamp(jwt_decoded["exp"], tz=UTC) 72 73 def _needs_refresh(self) -> bool: 74 if self.access_token is None or self.expires_at is None: 75 return True 76 # Refresh token 30 seconds before expiration to avoid race conditions 77 return datetime.now(tz=UTC) > self.expires_at - timedelta(seconds=30) 78 79 def _get_access_token(self) -> M2MTokenResponse: 80 """Get access token using client credentials flow.""" 81 if self.auth_api_url is None: 82 raise ClientAuthError("No auth API URL available") 83 84 data = { 85 "grant_type": "client_credentials", 86 "client_id": self.client_id, 87 "client_secret": self.client_secret, 88 "scope": self.scope, 89 } 90 91 response = httpx.post( 92 self.auth_api_url + "/oauth2/token", 93 headers={"Content-Type": "application/x-www-form-urlencoded"}, 94 data=data, 95 ) 96 97 if not response.is_success: 98 error_data = ( 99 response.json() if response.headers.get("content-type", "").startswith("application/json") else {} 100 ) 101 error_msg = error_data.get("error_description", f"HTTP {response.status_code}") 102 raise ClientAuthError(f"Failed to obtain access token: {error_msg}") 103 104 token_data = response.json() 105 return M2MTokenResponse.model_validate(token_data) 106 107 def _refresh_access_token(self) -> M2MTokenResponse: 108 """Refresh access token using refresh token if available, otherwise get new token.""" 109 110 if self.auth_api_url is None: 111 raise ClientAuthError("No auth API URL available") 112 113 if self.refresh_token: 114 data = { 115 "grant_type": "refresh_token", 116 "client_id": self.client_id, 117 "client_secret": self.client_secret, 118 "refresh_token": self.refresh_token, 119 "scope": self.scope, 120 } 121 122 response = httpx.post( 123 self.auth_api_url + "/oauth2/token", 124 headers={"Content-Type": "application/x-www-form-urlencoded"}, 125 data=data, 126 ) 127 128 if response.is_success: 129 token_data = response.json() 130 return M2MTokenResponse.model_validate(token_data) 131 132 # If refresh failed or no refresh token, get a new access token 133 return self._get_access_token() 134 135 def auth_flow(self, request: httpx.Request) -> Generator[httpx.Request, httpx.Response, None]: 136 """Auth flow implementation for httpx.Auth interface.""" 137 138 if self.auth_api_url is None: 139 # Get the base api url from the request, so we can fetch the signin api url from it. 140 base_url = request.url.netloc.decode("utf-8") 141 try: 142 response = yield httpx.Request( 143 url=f"https://{base_url}/auth:apiStrategy", 144 method="GET", 145 headers={ 146 "User-Agent": request.headers.get("User-Agent"), 147 }, 148 ) 149 response.read() 150 response.raise_for_status() 151 response_data = response.json() 152 if "auth_api_url" not in response_data: 153 raise ClientAuthError("Failed to retrieve auth API URL") 154 self.auth_api_url = response_data["auth_api_url"].rstrip("/") 155 except httpx.HTTPError as e: 156 raise ClientAuthError(f"Failed to discover auth API URL: {e}") from e 157 if self._needs_refresh(): 158 try: 159 if self.token is None: 160 self.token = self._get_access_token() 161 else: 162 self.token = self._refresh_access_token() 163 self._token_acquired_at = time.time() 164 except (httpx.HTTPError, ValueError) as e: 165 raise ClientAuthError(f"Failed to authenticate: {e}") from e 166 167 if self.token is None: 168 raise ClientAuthError("No valid token available") 169 170 request.headers["Authorization"] = f"Bearer m2m_{self.token.access_token}" 171 yield request
class
ClientAuthError(builtins.Exception):
13class ClientAuthError(Exception): 14 def __init__(self, message: str): 15 self.message = message 16 super().__init__(f"M2M Authentication Error: {message}")
Common base class for all non-exit exceptions.
class
M2MTokenResponse(pydantic.main.BaseModel):
class
M2MAuth(httpx.Auth):
27class M2MAuth(httpx.Auth): 28 """WorkOS Machine-to-Machine authentication using OAuth2 client credentials flow.""" 29 30 def __init__( 31 self, 32 client_id: str, 33 client_secret: str, 34 auth_api_url: str | None = None, 35 scope: str = "openid profile", 36 ): 37 self.client_id = client_id 38 self.client_secret = client_secret 39 self.auth_api_url = auth_api_url.rstrip("/") if auth_api_url is not None else None 40 self.scope = scope 41 self.token: M2MTokenResponse | None = None 42 self._token_acquired_at: float | None = None 43 44 @staticmethod 45 def from_json_file(file_path: Path) -> "M2MAuth": 46 json_data = json.loads(file_path.read_text("utf-8")) 47 return M2MAuth.from_json(json_data) 48 49 @staticmethod 50 def from_json(json: dict) -> "M2MAuth": 51 client_id = json.get("client_id") 52 client_secret = json.get("client_secret") 53 if not client_id or not client_secret: 54 raise ClientAuthError("Client ID or client secret is not set") 55 return M2MAuth(client_id=client_id, client_secret=client_secret) 56 57 @property 58 def access_token(self) -> str | None: 59 return self.token.access_token if self.token else None 60 61 @property 62 def refresh_token(self) -> str | None: 63 return self.token.refresh_token if self.token else None 64 65 @property 66 def expires_at(self) -> datetime | None: 67 if self.token is None or self._token_acquired_at is None: 68 return None 69 # Per https://github.com/jpadilla/pyjwt/issues/939 - we should not be verifying the `iat` time here 70 # as it could be slightly in the future. 71 jwt_decoded = jwt.decode(self.token.access_token, options={"verify_signature": False, "verify_iat": False}) 72 return datetime.fromtimestamp(jwt_decoded["exp"], tz=UTC) 73 74 def _needs_refresh(self) -> bool: 75 if self.access_token is None or self.expires_at is None: 76 return True 77 # Refresh token 30 seconds before expiration to avoid race conditions 78 return datetime.now(tz=UTC) > self.expires_at - timedelta(seconds=30) 79 80 def _get_access_token(self) -> M2MTokenResponse: 81 """Get access token using client credentials flow.""" 82 if self.auth_api_url is None: 83 raise ClientAuthError("No auth API URL available") 84 85 data = { 86 "grant_type": "client_credentials", 87 "client_id": self.client_id, 88 "client_secret": self.client_secret, 89 "scope": self.scope, 90 } 91 92 response = httpx.post( 93 self.auth_api_url + "/oauth2/token", 94 headers={"Content-Type": "application/x-www-form-urlencoded"}, 95 data=data, 96 ) 97 98 if not response.is_success: 99 error_data = ( 100 response.json() if response.headers.get("content-type", "").startswith("application/json") else {} 101 ) 102 error_msg = error_data.get("error_description", f"HTTP {response.status_code}") 103 raise ClientAuthError(f"Failed to obtain access token: {error_msg}") 104 105 token_data = response.json() 106 return M2MTokenResponse.model_validate(token_data) 107 108 def _refresh_access_token(self) -> M2MTokenResponse: 109 """Refresh access token using refresh token if available, otherwise get new token.""" 110 111 if self.auth_api_url is None: 112 raise ClientAuthError("No auth API URL available") 113 114 if self.refresh_token: 115 data = { 116 "grant_type": "refresh_token", 117 "client_id": self.client_id, 118 "client_secret": self.client_secret, 119 "refresh_token": self.refresh_token, 120 "scope": self.scope, 121 } 122 123 response = httpx.post( 124 self.auth_api_url + "/oauth2/token", 125 headers={"Content-Type": "application/x-www-form-urlencoded"}, 126 data=data, 127 ) 128 129 if response.is_success: 130 token_data = response.json() 131 return M2MTokenResponse.model_validate(token_data) 132 133 # If refresh failed or no refresh token, get a new access token 134 return self._get_access_token() 135 136 def auth_flow(self, request: httpx.Request) -> Generator[httpx.Request, httpx.Response, None]: 137 """Auth flow implementation for httpx.Auth interface.""" 138 139 if self.auth_api_url is None: 140 # Get the base api url from the request, so we can fetch the signin api url from it. 141 base_url = request.url.netloc.decode("utf-8") 142 try: 143 response = yield httpx.Request( 144 url=f"https://{base_url}/auth:apiStrategy", 145 method="GET", 146 headers={ 147 "User-Agent": request.headers.get("User-Agent"), 148 }, 149 ) 150 response.read() 151 response.raise_for_status() 152 response_data = response.json() 153 if "auth_api_url" not in response_data: 154 raise ClientAuthError("Failed to retrieve auth API URL") 155 self.auth_api_url = response_data["auth_api_url"].rstrip("/") 156 except httpx.HTTPError as e: 157 raise ClientAuthError(f"Failed to discover auth API URL: {e}") from e 158 if self._needs_refresh(): 159 try: 160 if self.token is None: 161 self.token = self._get_access_token() 162 else: 163 self.token = self._refresh_access_token() 164 self._token_acquired_at = time.time() 165 except (httpx.HTTPError, ValueError) as e: 166 raise ClientAuthError(f"Failed to authenticate: {e}") from e 167 168 if self.token is None: 169 raise ClientAuthError("No valid token available") 170 171 request.headers["Authorization"] = f"Bearer m2m_{self.token.access_token}" 172 yield request
WorkOS Machine-to-Machine authentication using OAuth2 client credentials flow.
M2MAuth( client_id: str, client_secret: str, auth_api_url: str | None = None, scope: str = 'openid profile')
30 def __init__( 31 self, 32 client_id: str, 33 client_secret: str, 34 auth_api_url: str | None = None, 35 scope: str = "openid profile", 36 ): 37 self.client_id = client_id 38 self.client_secret = client_secret 39 self.auth_api_url = auth_api_url.rstrip("/") if auth_api_url is not None else None 40 self.scope = scope 41 self.token: M2MTokenResponse | None = None 42 self._token_acquired_at: float | None = None
token: M2MTokenResponse | None
49 @staticmethod 50 def from_json(json: dict) -> "M2MAuth": 51 client_id = json.get("client_id") 52 client_secret = json.get("client_secret") 53 if not client_id or not client_secret: 54 raise ClientAuthError("Client ID or client secret is not set") 55 return M2MAuth(client_id=client_id, client_secret=client_secret)
expires_at: datetime.datetime | None
65 @property 66 def expires_at(self) -> datetime | None: 67 if self.token is None or self._token_acquired_at is None: 68 return None 69 # Per https://github.com/jpadilla/pyjwt/issues/939 - we should not be verifying the `iat` time here 70 # as it could be slightly in the future. 71 jwt_decoded = jwt.decode(self.token.access_token, options={"verify_signature": False, "verify_iat": False}) 72 return datetime.fromtimestamp(jwt_decoded["exp"], tz=UTC)
def
auth_flow( self, request: httpx.Request) -> Generator[httpx.Request, httpx.Response, None]:
136 def auth_flow(self, request: httpx.Request) -> Generator[httpx.Request, httpx.Response, None]: 137 """Auth flow implementation for httpx.Auth interface.""" 138 139 if self.auth_api_url is None: 140 # Get the base api url from the request, so we can fetch the signin api url from it. 141 base_url = request.url.netloc.decode("utf-8") 142 try: 143 response = yield httpx.Request( 144 url=f"https://{base_url}/auth:apiStrategy", 145 method="GET", 146 headers={ 147 "User-Agent": request.headers.get("User-Agent"), 148 }, 149 ) 150 response.read() 151 response.raise_for_status() 152 response_data = response.json() 153 if "auth_api_url" not in response_data: 154 raise ClientAuthError("Failed to retrieve auth API URL") 155 self.auth_api_url = response_data["auth_api_url"].rstrip("/") 156 except httpx.HTTPError as e: 157 raise ClientAuthError(f"Failed to discover auth API URL: {e}") from e 158 if self._needs_refresh(): 159 try: 160 if self.token is None: 161 self.token = self._get_access_token() 162 else: 163 self.token = self._refresh_access_token() 164 self._token_acquired_at = time.time() 165 except (httpx.HTTPError, ValueError) as e: 166 raise ClientAuthError(f"Failed to authenticate: {e}") from e 167 168 if self.token is None: 169 raise ClientAuthError("No valid token available") 170 171 request.headers["Authorization"] = f"Bearer m2m_{self.token.access_token}" 172 yield request
Auth flow implementation for httpx.Auth interface.