Coverage for python_carrier_infinity/api.py: 97%
49 statements
« prev ^ index » next coverage.py v6.5.0, created at 2023-08-19 01:56 +0000
« prev ^ index » next coverage.py v6.5.0, created at 2023-08-19 01:56 +0000
1"""Authentication and making GraphQL queries"""
2import base64
3import hashlib
4import secrets
5import string
6import aiohttp
9class Auth:
10 """Represents authentication to the API service"""
12 def __init__(self, username: str, access_token: str):
13 self.username = username
14 self._access_token = access_token
16 def get_access_token(self) -> str:
17 """Returns an OAuth 2.0 access token"""
18 # TODO: Add foo regarding if token is past expiration time
19 return self._access_token
22def random_alphanumeric(length: int) -> str:
23 """Generate random string"""
24 return "".join(
25 secrets.choice(string.ascii_letters + string.digits) for i in range(length)
26 )
29async def get_session_token(
30 session: aiohttp.ClientSession, username: str, password: str
31) -> str:
32 """Use login credentials to obtain session token"""
33 async with session.request(
34 "POST",
35 "/api/v1/authn",
36 json={
37 "password": password,
38 "username": username,
39 },
40 ) as response:
41 response_json = await response.json()
43 if "sessionToken" not in response_json:
44 raise Exception("sessionToken missing")
46 return response_json["sessionToken"]
49async def get_code_and_code_verifier(
50 session: aiohttp.ClientSession,
51 client_id: str,
52 session_token: str,
53 redirect_uri: str,
54) -> tuple[str, str]:
55 """Get short-lived code from redirect location param via code challenge & session token"""
56 code_verifier = random_alphanumeric(64)
58 # Base64 URL-encoded SHA256 hash
59 code_challenge = (
60 base64.urlsafe_b64encode(hashlib.sha256(code_verifier.encode("utf-8")).digest())
61 .decode("utf-8")
62 .replace("=", "")
63 )
65 nonce = random_alphanumeric(64)
66 state = "None"
68 async with session.request(
69 "GET",
70 "/oauth2/default/v1/authorize",
71 params={
72 "nonce": nonce,
73 "sessionToken": session_token,
74 "response_type": "code",
75 "code_challenge_method": "S256",
76 "scope": "openid profile offline_access",
77 "code_challenge": code_challenge,
78 "redirect_uri": redirect_uri,
79 "client_id": client_id,
80 "state": state,
81 },
82 allow_redirects=False,
83 ) as response:
84 code = (
85 response.headers["location"]
86 .replace(redirect_uri + "?", "")
87 .split("&")[0]
88 .split("=")[1]
89 )
90 return (code, code_verifier)
93async def get_access_token(
94 session: aiohttp.ClientSession,
95 client_id: str,
96 code: str,
97 code_verifier: str,
98 redirect_uri: str,
99) -> str:
100 """Use short-lived code to get access token for GraphQL operations"""
102 async with session.request(
103 "POST",
104 "/oauth2/default/v1/token",
105 data={
106 "grant_type": "authorization_code",
107 "redirect_uri": redirect_uri,
108 "code": code,
109 "code_verifier": code_verifier,
110 "client_id": client_id,
111 },
112 ) as response:
113 response_json = await response.json()
115 if "access_token" not in response_json: 115 ↛ 116line 115 didn't jump to line 116, because the condition on line 115 was never true
116 raise Exception("Access token was not found / granted")
118 return response_json["access_token"]
121async def login(username: str, password: str) -> Auth:
122 """Login to the API and return an Auth object"""
124 # Reference: https://developer.okta.com/docs/guides/implement-grant-type/authcodepkce/main/#create-the-proof-key-for-code-exchange # pylint: disable=line-too-long
126 base_url = "https://sso.carrier.com"
127 client_id = "0oa1ce7hwjuZbfOMB4x7"
128 redirect_uri = "com.carrier.homeowner:/login"
129 headers = {"Accept": "application/json"}
130 async with aiohttp.ClientSession(base_url, headers=headers) as session:
131 session_token = await get_session_token(session, username, password)
132 code, code_verifier = await get_code_and_code_verifier(
133 session, client_id, session_token, redirect_uri
134 )
135 access_token = await get_access_token(
136 session, client_id, code, code_verifier, redirect_uri
137 )
139 return Auth(username, access_token)
142async def gql_request(query: dict, auth: Auth) -> dict:
143 """Make a GraphQL request"""
144 url = "https://dataservice.infinity.iot.carrier.com/graphql"
145 headers = {
146 "Authorization": "Bearer " + auth.get_access_token(),
147 }
149 async with aiohttp.ClientSession() as session:
150 async with session.request(
151 "POST", url, headers=headers, json=query
152 ) as response:
153 return await response.json()