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.

ClientAuthError(message: str)
14    def __init__(self, message: str):
15        self.message = message
16        super().__init__(f"M2M Authentication Error: {message}")
message
class M2MTokenResponse(pydantic.main.BaseModel):
19class M2MTokenResponse(BaseModel):
20    access_token: str
21    token_type: str = "Bearer"
22    expires_in: int
23    refresh_token: str | None = None
24    scope: str | None = None
access_token: str = PydanticUndefined
token_type: str = 'Bearer'
expires_in: int = PydanticUndefined
refresh_token: str | None = None
scope: str | None = None
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
client_id
client_secret
auth_api_url
scope
token: M2MTokenResponse | None
@staticmethod
def from_json_file(file_path: pathlib.Path) -> M2MAuth:
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)
@staticmethod
def from_json(json: dict) -> M2MAuth:
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)
access_token: str | None
57    @property
58    def access_token(self) -> str | None:
59        return self.token.access_token if self.token else None
refresh_token: str | None
61    @property
62    def refresh_token(self) -> str | None:
63        return self.token.refresh_token if self.token else None
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.