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

1"""Authentication and making GraphQL queries""" 

2import base64 

3import hashlib 

4import secrets 

5import string 

6import aiohttp 

7 

8 

9class Auth: 

10 """Represents authentication to the API service""" 

11 

12 def __init__(self, username: str, access_token: str): 

13 self.username = username 

14 self._access_token = access_token 

15 

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 

20 

21 

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 ) 

27 

28 

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() 

42 

43 if "sessionToken" not in response_json: 

44 raise Exception("sessionToken missing") 

45 

46 return response_json["sessionToken"] 

47 

48 

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) 

57 

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 ) 

64 

65 nonce = random_alphanumeric(64) 

66 state = "None" 

67 

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) 

91 

92 

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""" 

101 

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() 

114 

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") 

117 

118 return response_json["access_token"] 

119 

120 

121async def login(username: str, password: str) -> Auth: 

122 """Login to the API and return an Auth object""" 

123 

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 

125 

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 ) 

138 

139 return Auth(username, access_token) 

140 

141 

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 } 

148 

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()