Coverage for /pythoncovmergedfiles/medio/medio/usr/local/lib/python3.8/site-packages/msal/oauth2cli/oauth2.py: 23%

257 statements  

« prev     ^ index     » next       coverage.py v7.2.7, created at 2023-06-07 06:20 +0000

1"""This OAuth2 client implementation aims to be spec-compliant, and generic.""" 

2# OAuth2 spec https://tools.ietf.org/html/rfc6749 

3 

4import json 

5try: 

6 from urllib.parse import urlencode, parse_qs, quote_plus, urlparse, urlunparse 

7except ImportError: 

8 from urlparse import parse_qs, urlparse, urlunparse 

9 from urllib import urlencode, quote_plus 

10import logging 

11import warnings 

12import time 

13import base64 

14import sys 

15import functools 

16import random 

17import string 

18import hashlib 

19 

20from .authcode import AuthCodeReceiver as _AuthCodeReceiver 

21 

22try: 

23 PermissionError # Available in Python 3 

24except: 

25 from socket import error as PermissionError # Workaround for Python 2 

26 

27 

28string_types = (str,) if sys.version_info[0] >= 3 else (basestring, ) 

29 

30 

31class BaseClient(object): 

32 # This low-level interface works. Yet you'll find its sub-class 

33 # more friendly to remind you what parameters are needed in each scenario. 

34 # More on Client Types at https://tools.ietf.org/html/rfc6749#section-2.1 

35 

36 @staticmethod 

37 def encode_saml_assertion(assertion): 

38 return base64.urlsafe_b64encode(assertion).rstrip(b'=') # Per RFC 7522 

39 

40 CLIENT_ASSERTION_TYPE_JWT = "urn:ietf:params:oauth:client-assertion-type:jwt-bearer" 

41 CLIENT_ASSERTION_TYPE_SAML2 = "urn:ietf:params:oauth:client-assertion-type:saml2-bearer" 

42 client_assertion_encoders = {CLIENT_ASSERTION_TYPE_SAML2: encode_saml_assertion} 

43 

44 @property 

45 def session(self): 

46 warnings.warn("Will be gone in next major release", DeprecationWarning) 

47 return self._http_client 

48 

49 @session.setter 

50 def session(self, value): 

51 warnings.warn("Will be gone in next major release", DeprecationWarning) 

52 self._http_client = value 

53 

54 

55 def __init__( 

56 self, 

57 server_configuration, # type: dict 

58 client_id, # type: str 

59 http_client=None, # We insert it here to match the upcoming async API 

60 client_secret=None, # type: Optional[str] 

61 client_assertion=None, # type: Union[bytes, callable, None] 

62 client_assertion_type=None, # type: Optional[str] 

63 default_headers=None, # type: Optional[dict] 

64 default_body=None, # type: Optional[dict] 

65 verify=None, # type: Union[str, True, False, None] 

66 proxies=None, # type: Optional[dict] 

67 timeout=None, # type: Union[tuple, float, None] 

68 ): 

69 """Initialize a client object to talk all the OAuth2 grants to the server. 

70 

71 Args: 

72 server_configuration (dict): 

73 It contains the configuration (i.e. metadata) of the auth server. 

74 The actual content typically contains keys like 

75 "authorization_endpoint", "token_endpoint", etc.. 

76 Based on RFC 8414 (https://tools.ietf.org/html/rfc8414), 

77 you can probably fetch it online from either 

78 https://example.com/.../.well-known/oauth-authorization-server 

79 or 

80 https://example.com/.../.well-known/openid-configuration 

81 client_id (str): The client's id, issued by the authorization server 

82 

83 http_client (http.HttpClient): 

84 Your implementation of abstract class :class:`http.HttpClient`. 

85 Defaults to a requests session instance. 

86 

87 There is no session-wide `timeout` parameter defined here. 

88 Timeout behavior is determined by the actual http client you use. 

89 If you happen to use Requests, it disallows session-wide timeout 

90 (https://github.com/psf/requests/issues/3341). The workaround is: 

91 

92 s = requests.Session() 

93 s.request = functools.partial(s.request, timeout=3) 

94 

95 and then feed that patched session instance to this class. 

96 

97 client_secret (str): Triggers HTTP AUTH for Confidential Client 

98 client_assertion (bytes, callable): 

99 The client assertion to authenticate this client, per RFC 7521. 

100 It can be a raw SAML2 assertion (we will base64 encode it for you), 

101 or a raw JWT assertion in bytes (which we will relay to http layer). 

102 It can also be a callable (recommended), 

103 so that we will do lazy creation of an assertion. 

104 client_assertion_type (str): 

105 The type of your :attr:`client_assertion` parameter. 

106 It is typically the value of :attr:`CLIENT_ASSERTION_TYPE_SAML2` or 

107 :attr:`CLIENT_ASSERTION_TYPE_JWT`, the only two defined in RFC 7521. 

108 default_headers (dict): 

109 A dict to be sent in each request header. 

110 It is not required by OAuth2 specs, but you may use it for telemetry. 

111 default_body (dict): 

112 A dict to be sent in each token request body. For example, 

113 you could choose to set this as {"client_secret": "your secret"} 

114 if your authorization server wants it to be in the request body 

115 (rather than in the request header). 

116 

117 verify (boolean): 

118 It will be passed to the 

119 `verify parameter in the underlying requests library 

120 <http://docs.python-requests.org/en/v2.9.1/user/advanced/#ssl-cert-verification>`_. 

121 When leaving it with default value (None), we will use True instead. 

122 

123 This does not apply if you have chosen to pass your own Http client. 

124 

125 proxies (dict): 

126 It will be passed to the 

127 `proxies parameter in the underlying requests library 

128 <http://docs.python-requests.org/en/v2.9.1/user/advanced/#proxies>`_. 

129 

130 This does not apply if you have chosen to pass your own Http client. 

131 

132 timeout (object): 

133 It will be passed to the 

134 `timeout parameter in the underlying requests library 

135 <http://docs.python-requests.org/en/v2.9.1/user/advanced/#timeouts>`_. 

136 

137 This does not apply if you have chosen to pass your own Http client. 

138 

139 """ 

140 if not server_configuration: 

141 raise ValueError("Missing input parameter server_configuration") 

142 # Generally we should have client_id, but we tolerate its absence 

143 self.configuration = server_configuration 

144 self.client_id = client_id 

145 self.client_secret = client_secret 

146 self.client_assertion = client_assertion 

147 self.default_headers = default_headers or {} 

148 self.default_body = default_body or {} 

149 if client_assertion_type is not None: 

150 self.default_body["client_assertion_type"] = client_assertion_type 

151 self.logger = logging.getLogger(__name__) 

152 if http_client: 

153 if verify is not None or proxies is not None or timeout is not None: 

154 raise ValueError( 

155 "verify, proxies, or timeout is not allowed " 

156 "when http_client is in use") 

157 self._http_client = http_client 

158 else: 

159 import requests # Lazy loading 

160 

161 self._http_client = requests.Session() 

162 self._http_client.verify = True if verify is None else verify 

163 self._http_client.proxies = proxies 

164 self._http_client.request = functools.partial( 

165 # A workaround for requests not supporting session-wide timeout 

166 self._http_client.request, timeout=timeout) 

167 

168 def _build_auth_request_params(self, response_type, **kwargs): 

169 # response_type is a string defined in 

170 # https://tools.ietf.org/html/rfc6749#section-3.1.1 

171 # or it can be a space-delimited string as defined in 

172 # https://tools.ietf.org/html/rfc6749#section-8.4 

173 response_type = self._stringify(response_type) 

174 

175 params = {'client_id': self.client_id, 'response_type': response_type} 

176 params.update(kwargs) # Note: None values will override params 

177 params = {k: v for k, v in params.items() if v is not None} # clean up 

178 if params.get('scope'): 

179 params['scope'] = self._stringify(params['scope']) 

180 return params # A dict suitable to be used in http request 

181 

182 def _obtain_token( # The verb "obtain" is influenced by OAUTH2 RFC 6749 

183 self, grant_type, 

184 params=None, # a dict to be sent as query string to the endpoint 

185 data=None, # All relevant data, which will go into the http body 

186 headers=None, # a dict to be sent as request headers 

187 post=None, # A callable to replace requests.post(), for testing. 

188 # Such as: lambda url, **kwargs: 

189 # Mock(status_code=200, text='{}') 

190 **kwargs # Relay all extra parameters to underlying requests 

191 ): # Returns the json object came from the OAUTH2 response 

192 _data = {'client_id': self.client_id, 'grant_type': grant_type} 

193 

194 if self.default_body.get("client_assertion_type") and self.client_assertion: 

195 # See https://tools.ietf.org/html/rfc7521#section-4.2 

196 encoder = self.client_assertion_encoders.get( 

197 self.default_body["client_assertion_type"], lambda a: a) 

198 _data["client_assertion"] = encoder( 

199 self.client_assertion() # Do lazy on-the-fly computation 

200 if callable(self.client_assertion) else self.client_assertion 

201 ) # The type is bytes, which is preferable. See also: 

202 # https://github.com/psf/requests/issues/4503#issuecomment-455001070 

203 

204 _data.update(self.default_body) # It may contain authen parameters 

205 _data.update(data or {}) # So the content in data param prevails 

206 _data = {k: v for k, v in _data.items() if v} # Clean up None values 

207 

208 if _data.get('scope'): 

209 _data['scope'] = self._stringify(_data['scope']) 

210 

211 _headers = {'Accept': 'application/json'} 

212 _headers.update(self.default_headers) 

213 _headers.update(headers or {}) 

214 

215 # Quoted from https://tools.ietf.org/html/rfc6749#section-2.3.1 

216 # Clients in possession of a client password MAY use the HTTP Basic 

217 # authentication. 

218 # Alternatively, (but NOT RECOMMENDED,) 

219 # the authorization server MAY support including the 

220 # client credentials in the request-body using the following 

221 # parameters: client_id, client_secret. 

222 if self.client_secret and self.client_id: 

223 _headers["Authorization"] = "Basic " + base64.b64encode("{}:{}".format( 

224 # Per https://tools.ietf.org/html/rfc6749#section-2.3.1 

225 # client_id and client_secret needs to be encoded by 

226 # "application/x-www-form-urlencoded" 

227 # https://www.w3.org/TR/html401/interact/forms.html#h-17.13.4.1 

228 # BEFORE they are fed into HTTP Basic Authentication 

229 quote_plus(self.client_id), quote_plus(self.client_secret) 

230 ).encode("ascii")).decode("ascii") 

231 

232 if "token_endpoint" not in self.configuration: 

233 raise ValueError("token_endpoint not found in configuration") 

234 resp = (post or self._http_client.post)( 

235 self.configuration["token_endpoint"], 

236 headers=_headers, params=params, data=_data, 

237 **kwargs) 

238 if resp.status_code >= 500: 

239 resp.raise_for_status() # TODO: Will probably retry here 

240 try: 

241 # The spec (https://tools.ietf.org/html/rfc6749#section-5.2) says 

242 # even an error response will be a valid json structure, 

243 # so we simply return it here, without needing to invent an exception. 

244 return json.loads(resp.text) 

245 except ValueError: 

246 self.logger.exception( 

247 "Token response is not in json format: %s", resp.text) 

248 raise 

249 

250 def obtain_token_by_refresh_token(self, refresh_token, scope=None, **kwargs): 

251 # type: (str, Union[str, list, set, tuple]) -> dict 

252 """Obtain an access token via a refresh token. 

253 

254 :param refresh_token: The refresh token issued to the client 

255 :param scope: If omitted, is treated as equal to the scope originally 

256 granted by the resource owner, 

257 according to https://tools.ietf.org/html/rfc6749#section-6 

258 """ 

259 assert isinstance(refresh_token, string_types) 

260 data = kwargs.pop('data', {}) 

261 data.update(refresh_token=refresh_token, scope=scope) 

262 return self._obtain_token("refresh_token", data=data, **kwargs) 

263 

264 def _stringify(self, sequence): 

265 if isinstance(sequence, (list, set, tuple)): 

266 return ' '.join(sorted(sequence)) # normalizing it, ascendingly 

267 return sequence # as-is 

268 

269 

270def _scope_set(scope): 

271 assert scope is None or isinstance(scope, (list, set, tuple)) 

272 return set(scope) if scope else set([]) 

273 

274 

275def _generate_pkce_code_verifier(length=43): 

276 assert 43 <= length <= 128 

277 verifier = "".join( # https://tools.ietf.org/html/rfc7636#section-4.1 

278 random.sample(string.ascii_letters + string.digits + "-._~", length)) 

279 code_challenge = ( 

280 # https://tools.ietf.org/html/rfc7636#section-4.2 

281 base64.urlsafe_b64encode(hashlib.sha256(verifier.encode("ascii")).digest()) 

282 .rstrip(b"=")) # Required by https://tools.ietf.org/html/rfc7636#section-3 

283 return { 

284 "code_verifier": verifier, 

285 "transformation": "S256", # In Python, sha256 is always available 

286 "code_challenge": code_challenge, 

287 } 

288 

289 

290class Client(BaseClient): # We choose to implement all 4 grants in 1 class 

291 """This is the main API for oauth2 client. 

292 

293 Its methods define and document parameters mentioned in OAUTH2 RFC 6749. 

294 """ 

295 DEVICE_FLOW = { # consts for device flow, that can be customized by sub-class 

296 "GRANT_TYPE": "urn:ietf:params:oauth:grant-type:device_code", 

297 "DEVICE_CODE": "device_code", 

298 } 

299 DEVICE_FLOW_RETRIABLE_ERRORS = ("authorization_pending", "slow_down") 

300 GRANT_TYPE_SAML2 = "urn:ietf:params:oauth:grant-type:saml2-bearer" # RFC7522 

301 GRANT_TYPE_JWT = "urn:ietf:params:oauth:grant-type:jwt-bearer" # RFC7523 

302 grant_assertion_encoders = {GRANT_TYPE_SAML2: BaseClient.encode_saml_assertion} 

303 

304 

305 def initiate_device_flow(self, scope=None, **kwargs): 

306 # type: (list, **dict) -> dict 

307 # The naming of this method is following the wording of this specs 

308 # https://tools.ietf.org/html/draft-ietf-oauth-device-flow-12#section-3.1 

309 """Initiate a device flow. 

310 

311 Returns the data defined in Device Flow specs. 

312 https://tools.ietf.org/html/draft-ietf-oauth-device-flow-12#section-3.2 

313 

314 You should then orchestrate the User Interaction as defined in here 

315 https://tools.ietf.org/html/draft-ietf-oauth-device-flow-12#section-3.3 

316 

317 And possibly here 

318 https://tools.ietf.org/html/draft-ietf-oauth-device-flow-12#section-3.3.1 

319 """ 

320 DAE = "device_authorization_endpoint" 

321 if not self.configuration.get(DAE): 

322 raise ValueError("You need to provide device authorization endpoint") 

323 resp = self._http_client.post(self.configuration[DAE], 

324 data={"client_id": self.client_id, "scope": self._stringify(scope or [])}, 

325 headers=dict(self.default_headers, **kwargs.pop("headers", {})), 

326 **kwargs) 

327 flow = json.loads(resp.text) 

328 flow["interval"] = int(flow.get("interval", 5)) # Some IdP returns string 

329 flow["expires_in"] = int(flow.get("expires_in", 1800)) 

330 flow["expires_at"] = time.time() + flow["expires_in"] # We invent this 

331 return flow 

332 

333 def _obtain_token_by_device_flow(self, flow, **kwargs): 

334 # type: (dict, **dict) -> dict 

335 # This method updates flow during each run. And it is non-blocking. 

336 now = time.time() 

337 skew = 1 

338 if flow.get("latest_attempt_at", 0) + flow.get("interval", 5) - skew > now: 

339 warnings.warn('Attempted too soon. Please do time.sleep(flow["interval"])') 

340 data = kwargs.pop("data", {}) 

341 data.update({ 

342 "client_id": self.client_id, 

343 self.DEVICE_FLOW["DEVICE_CODE"]: flow["device_code"], 

344 }) 

345 result = self._obtain_token( 

346 self.DEVICE_FLOW["GRANT_TYPE"], data=data, **kwargs) 

347 if result.get("error") == "slow_down": 

348 # Respecting https://tools.ietf.org/html/draft-ietf-oauth-device-flow-12#section-3.5 

349 flow["interval"] = flow.get("interval", 5) + 5 

350 flow["latest_attempt_at"] = now 

351 return result 

352 

353 def obtain_token_by_device_flow(self, 

354 flow, 

355 exit_condition=lambda flow: flow.get("expires_at", 0) < time.time(), 

356 **kwargs): 

357 # type: (dict, Callable) -> dict 

358 """Obtain token by a device flow object, with customizable polling effect. 

359 

360 Args: 

361 flow (dict): 

362 An object previously generated by initiate_device_flow(...). 

363 Its content WILL BE CHANGED by this method during each run. 

364 We share this object with you, so that you could implement 

365 your own loop, should you choose to do so. 

366 

367 exit_condition (Callable): 

368 This method implements a loop to provide polling effect. 

369 The loop's exit condition is calculated by this callback. 

370 

371 The default callback makes the loop run until the flow expires. 

372 Therefore, one of the ways to exit the polling early, 

373 is to change the flow["expires_at"] to a small number such as 0. 

374 

375 In case you are doing async programming, you may want to 

376 completely turn off the loop. You can do so by using a callback as: 

377 

378 exit_condition = lambda flow: True 

379 

380 to make the loop run only once, i.e. no polling, hence non-block. 

381 """ 

382 while True: 

383 result = self._obtain_token_by_device_flow(flow, **kwargs) 

384 if result.get("error") not in self.DEVICE_FLOW_RETRIABLE_ERRORS: 

385 return result 

386 for i in range(flow.get("interval", 5)): # Wait interval seconds 

387 if exit_condition(flow): 

388 return result 

389 time.sleep(1) # Shorten each round, to make exit more responsive 

390 

391 def _build_auth_request_uri( 

392 self, 

393 response_type, redirect_uri=None, scope=None, state=None, **kwargs): 

394 if "authorization_endpoint" not in self.configuration: 

395 raise ValueError("authorization_endpoint not found in configuration") 

396 authorization_endpoint = self.configuration["authorization_endpoint"] 

397 params = self._build_auth_request_params( 

398 response_type, redirect_uri=redirect_uri, scope=scope, state=state, 

399 **kwargs) 

400 sep = '&' if '?' in authorization_endpoint else '?' 

401 return "%s%s%s" % (authorization_endpoint, sep, urlencode(params)) 

402 

403 def build_auth_request_uri( 

404 self, 

405 response_type, redirect_uri=None, scope=None, state=None, **kwargs): 

406 # This method could be named build_authorization_request_uri() instead, 

407 # but then there would be a build_authentication_request_uri() in the OIDC 

408 # subclass doing almost the same thing. So we use a loose term "auth" here. 

409 """Generate an authorization uri to be visited by resource owner. 

410 

411 Parameters are the same as another method :func:`initiate_auth_code_flow()`, 

412 whose functionality is a superset of this method. 

413 

414 :return: The auth uri as a string. 

415 """ 

416 warnings.warn("Use initiate_auth_code_flow() instead. ", DeprecationWarning) 

417 return self._build_auth_request_uri( 

418 response_type, redirect_uri=redirect_uri, scope=scope, state=state, 

419 **kwargs) 

420 

421 def initiate_auth_code_flow( 

422 # The name is influenced by OIDC 

423 # https://openid.net/specs/openid-connect-core-1_0.html#CodeFlowAuth 

424 self, 

425 scope=None, redirect_uri=None, state=None, 

426 **kwargs): 

427 """Initiate an auth code flow. 

428 

429 Later when the response reaches your redirect_uri, 

430 you can use :func:`~obtain_token_by_auth_code_flow()` 

431 to complete the authentication/authorization. 

432 

433 This method also provides PKCE protection automatically. 

434 

435 :param list scope: 

436 It is a list of case-sensitive strings. 

437 Some ID provider can accept empty string to represent default scope. 

438 :param str redirect_uri: 

439 Optional. If not specified, server will use the pre-registered one. 

440 :param str state: 

441 An opaque value used by the client to 

442 maintain state between the request and callback. 

443 If absent, this library will automatically generate one internally. 

444 :param kwargs: Other parameters, typically defined in OpenID Connect. 

445 

446 :return: 

447 The auth code flow. It is a dict in this form:: 

448 

449 { 

450 "auth_uri": "https://...", // Guide user to visit this 

451 "state": "...", // You may choose to verify it by yourself, 

452 // or just let obtain_token_by_auth_code_flow() 

453 // do that for you. 

454 "...": "...", // Everything else are reserved and internal 

455 } 

456 

457 The caller is expected to:: 

458 

459 1. somehow store this content, typically inside the current session, 

460 2. guide the end user (i.e. resource owner) to visit that auth_uri, 

461 3. and then relay this dict and subsequent auth response to 

462 :func:`~obtain_token_by_auth_code_flow()`. 

463 """ 

464 response_type = kwargs.pop("response_type", "code") # Auth Code flow 

465 # Must be "code" when you are using Authorization Code Grant. 

466 # The "token" for Implicit Grant is not applicable thus not allowed. 

467 # It could theoretically be other 

468 # (possibly space-delimited) strings as registered extension value. 

469 # See https://tools.ietf.org/html/rfc6749#section-3.1.1 

470 if "token" in response_type: 

471 # Implicit grant would cause auth response coming back in #fragment, 

472 # but fragment won't reach a web service. 

473 raise ValueError('response_type="token ..." is not allowed') 

474 pkce = _generate_pkce_code_verifier() 

475 flow = { # These data are required by obtain_token_by_auth_code_flow() 

476 "state": state or "".join(random.sample(string.ascii_letters, 16)), 

477 "redirect_uri": redirect_uri, 

478 "scope": scope, 

479 } 

480 auth_uri = self._build_auth_request_uri( 

481 response_type, 

482 code_challenge=pkce["code_challenge"], 

483 code_challenge_method=pkce["transformation"], 

484 **dict(flow, **kwargs)) 

485 flow["auth_uri"] = auth_uri 

486 flow["code_verifier"] = pkce["code_verifier"] 

487 return flow 

488 

489 def obtain_token_by_auth_code_flow( 

490 self, 

491 auth_code_flow, 

492 auth_response, 

493 scope=None, 

494 **kwargs): 

495 """With the auth_response being redirected back, 

496 validate it against auth_code_flow, and then obtain tokens. 

497 

498 Internally, it implements PKCE to mitigate the auth code interception attack. 

499 

500 :param dict auth_code_flow: 

501 The same dict returned by :func:`~initiate_auth_code_flow()`. 

502 :param dict auth_response: 

503 A dict based on query string received from auth server. 

504 

505 :param scope: 

506 You don't usually need to use scope parameter here. 

507 Some Identity Provider allows you to provide 

508 a subset of what you specified during :func:`~initiate_auth_code_flow`. 

509 :type scope: collections.Iterable[str] 

510 

511 :return: 

512 * A dict containing "access_token" and/or "id_token", among others, 

513 depends on what scope was used. 

514 (See https://tools.ietf.org/html/rfc6749#section-5.1) 

515 * A dict containing "error", optionally "error_description", "error_uri". 

516 (It is either `this <https://tools.ietf.org/html/rfc6749#section-4.1.2.1>`_ 

517 or `that <https://tools.ietf.org/html/rfc6749#section-5.2>`_ 

518 * Most client-side data error would result in ValueError exception. 

519 So the usage pattern could be without any protocol details:: 

520 

521 def authorize(): # A controller in a web app 

522 try: 

523 result = client.obtain_token_by_auth_code_flow( 

524 session.get("flow", {}), auth_resp) 

525 if "error" in result: 

526 return render_template("error.html", result) 

527 store_tokens() 

528 except ValueError: # Usually caused by CSRF 

529 pass # Simply ignore them 

530 return redirect(url_for("index")) 

531 """ 

532 assert isinstance(auth_code_flow, dict) and isinstance(auth_response, dict) 

533 # This is app developer's error which we do NOT want to map to ValueError 

534 if not auth_code_flow.get("state"): 

535 # initiate_auth_code_flow() already guarantees a state to be available. 

536 # This check will also allow a web app to blindly call this method with 

537 # obtain_token_by_auth_code_flow(session.get("flow", {}), auth_resp) 

538 # which further simplifies their usage. 

539 raise ValueError("state missing from auth_code_flow") 

540 if auth_code_flow.get("state") != auth_response.get("state"): 

541 raise ValueError("state mismatch: {} vs {}".format( 

542 auth_code_flow.get("state"), auth_response.get("state"))) 

543 if scope and set(scope) - set(auth_code_flow.get("scope", [])): 

544 raise ValueError( 

545 "scope must be None or a subset of %s" % auth_code_flow.get("scope")) 

546 if auth_response.get("code"): # i.e. the first leg was successful 

547 return self._obtain_token_by_authorization_code( 

548 auth_response["code"], 

549 redirect_uri=auth_code_flow.get("redirect_uri"), 

550 # Required, if "redirect_uri" parameter was included in the 

551 # authorization request, and their values MUST be identical. 

552 scope=scope or auth_code_flow.get("scope"), 

553 # It is both unnecessary and harmless, per RFC 6749. 

554 # We use the same scope already used in auth request uri, 

555 # thus token cache can know what scope the tokens are for. 

556 data=dict( # Extract and update the data 

557 kwargs.pop("data", {}), 

558 code_verifier=auth_code_flow["code_verifier"], 

559 ), 

560 **kwargs) 

561 if auth_response.get("error"): # It means the first leg encountered error 

562 # Here we do NOT return original auth_response as-is, to prevent a 

563 # potential {..., "access_token": "attacker's AT"} input being leaked 

564 error = {"error": auth_response["error"]} 

565 if auth_response.get("error_description"): 

566 error["error_description"] = auth_response["error_description"] 

567 if auth_response.get("error_uri"): 

568 error["error_uri"] = auth_response["error_uri"] 

569 return error 

570 raise ValueError('auth_response must contain either "code" or "error"') 

571 

572 def obtain_token_by_browser( 

573 # Name influenced by RFC 8252: "native apps should (use) ... user's browser" 

574 self, 

575 redirect_uri=None, 

576 auth_code_receiver=None, 

577 **kwargs): 

578 """A native app can use this method to obtain token via a local browser. 

579 

580 Internally, it implements PKCE to mitigate the auth code interception attack. 

581 

582 :param scope: A list of scopes that you would like to obtain token for. 

583 :type scope: collections.Iterable[str] 

584 

585 :param extra_scope_to_consent: 

586 Some IdP allows you to include more scopes for end user to consent. 

587 The access token returned by this method will NOT include those scopes, 

588 but the refresh token would record those extra consent, 

589 so that your future :func:`~obtain_token_by_refresh_token()` call 

590 would be able to obtain token for those additional scopes, silently. 

591 :type scope: collections.Iterable[str] 

592 

593 :param string redirect_uri: 

594 The redirect_uri to be sent via auth request to Identity Provider (IdP), 

595 to indicate where an auth response would come back to. 

596 Such as ``http://127.0.0.1:0`` (default) or ``http://localhost:1234``. 

597 

598 If port 0 is specified, this method will choose a system-allocated port, 

599 then the actual redirect_uri will contain that port. 

600 To use this behavior, your IdP would need to accept such dynamic port. 

601 

602 Per HTTP convention, if port number is absent, it would mean port 80, 

603 although you probably want to specify port 0 in this context. 

604 

605 :param dict auth_params: 

606 These parameters will be sent to authorization_endpoint. 

607 

608 :param int timeout: In seconds. None means wait indefinitely. 

609 

610 :param str browser_name: 

611 If you did 

612 ``webbrowser.register("xyz", None, BackgroundBrowser("/path/to/browser"))`` 

613 beforehand, you can pass in the name "xyz" to use that browser. 

614 The default value ``None`` means using default browser, 

615 which is customizable by env var $BROWSER. 

616 

617 :return: Same as :func:`~obtain_token_by_auth_code_flow()` 

618 """ 

619 if auth_code_receiver: # Then caller already knows the listen port 

620 return self._obtain_token_by_browser( # Use all input param as-is 

621 auth_code_receiver, redirect_uri=redirect_uri, **kwargs) 

622 # Otherwise we will listen on _redirect_uri.port 

623 _redirect_uri = urlparse(redirect_uri or "http://127.0.0.1:0") 

624 if not _redirect_uri.hostname: 

625 raise ValueError("redirect_uri should contain hostname") 

626 listen_port = ( # Conventionally, port-less uri would mean port 80 

627 80 if _redirect_uri.port is None else _redirect_uri.port) 

628 try: 

629 with _AuthCodeReceiver(port=listen_port) as receiver: 

630 uri = redirect_uri if _redirect_uri.port != 0 else urlunparse(( 

631 _redirect_uri.scheme, 

632 "{}:{}".format(_redirect_uri.hostname, receiver.get_port()), 

633 _redirect_uri.path, 

634 _redirect_uri.params, 

635 _redirect_uri.query, 

636 _redirect_uri.fragment, 

637 )) # It could be slightly different than raw redirect_uri 

638 self.logger.debug("Using {} as redirect_uri".format(uri)) 

639 return self._obtain_token_by_browser( 

640 receiver, redirect_uri=uri, **kwargs) 

641 except PermissionError: 

642 raise ValueError( 

643 "Can't listen on port %s. You may try port 0." % listen_port) 

644 

645 def _obtain_token_by_browser( 

646 self, 

647 auth_code_receiver, 

648 scope=None, 

649 extra_scope_to_consent=None, 

650 redirect_uri=None, 

651 timeout=None, 

652 welcome_template=None, 

653 success_template=None, 

654 error_template=None, 

655 auth_params=None, 

656 auth_uri_callback=None, 

657 browser_name=None, 

658 **kwargs): 

659 # Internally, it calls self.initiate_auth_code_flow() and 

660 # self.obtain_token_by_auth_code_flow(). 

661 # 

662 # Parameters are documented in public method obtain_token_by_browser(). 

663 flow = self.initiate_auth_code_flow( 

664 redirect_uri=redirect_uri, 

665 scope=_scope_set(scope) | _scope_set(extra_scope_to_consent), 

666 **(auth_params or {})) 

667 auth_response = auth_code_receiver.get_auth_response( 

668 auth_uri=flow["auth_uri"], 

669 state=flow["state"], # Optional but we choose to do it upfront 

670 timeout=timeout, 

671 welcome_template=welcome_template, 

672 success_template=success_template, 

673 error_template=error_template, 

674 auth_uri_callback=auth_uri_callback, 

675 browser_name=browser_name, 

676 ) 

677 return self.obtain_token_by_auth_code_flow( 

678 flow, auth_response, scope=scope, **kwargs) 

679 

680 @staticmethod 

681 def parse_auth_response(params, state=None): 

682 """Parse the authorization response being redirected back. 

683 

684 :param params: A string or dict of the query string 

685 :param state: REQUIRED if the state parameter was present in the client 

686 authorization request. This function will compare it with response. 

687 """ 

688 warnings.warn( 

689 "Use obtain_token_by_auth_code_flow() instead", DeprecationWarning) 

690 if not isinstance(params, dict): 

691 params = parse_qs(params) 

692 if params.get('state') != state: 

693 raise ValueError('state mismatch') 

694 return params 

695 

696 def obtain_token_by_authorization_code( 

697 self, code, redirect_uri=None, scope=None, **kwargs): 

698 """Get a token via authorization code. a.k.a. Authorization Code Grant. 

699 

700 This is typically used by a server-side app (Confidential Client), 

701 but it can also be used by a device-side native app (Public Client). 

702 See more detail at https://tools.ietf.org/html/rfc6749#section-4.1.3 

703 

704 You are encouraged to use its higher level method 

705 :func:`~obtain_token_by_auth_code_flow` instead. 

706 

707 :param code: The authorization code received from authorization server. 

708 :param redirect_uri: 

709 Required, if the "redirect_uri" parameter was included in the 

710 authorization request, and their values MUST be identical. 

711 :param scope: 

712 It is both unnecessary and harmless to use scope here, per RFC 6749. 

713 We suggest to use the same scope already used in auth request uri, 

714 so that this library can link the obtained tokens with their scope. 

715 """ 

716 warnings.warn( 

717 "Use obtain_token_by_auth_code_flow() instead", DeprecationWarning) 

718 return self._obtain_token_by_authorization_code( 

719 code, redirect_uri=redirect_uri, scope=scope, **kwargs) 

720 

721 def _obtain_token_by_authorization_code( 

722 self, code, redirect_uri=None, scope=None, **kwargs): 

723 data = kwargs.pop("data", {}) 

724 data.update(code=code, redirect_uri=redirect_uri) 

725 if scope: 

726 data["scope"] = scope 

727 if not self.client_secret: 

728 # client_id is required, if the client is not authenticating itself. 

729 # See https://tools.ietf.org/html/rfc6749#section-4.1.3 

730 data["client_id"] = self.client_id 

731 return self._obtain_token("authorization_code", data=data, **kwargs) 

732 

733 def obtain_token_by_username_password( 

734 self, username, password, scope=None, **kwargs): 

735 """The Resource Owner Password Credentials Grant, used by legacy app.""" 

736 data = kwargs.pop("data", {}) 

737 data.update(username=username, password=password, scope=scope) 

738 return self._obtain_token("password", data=data, **kwargs) 

739 

740 def obtain_token_for_client(self, scope=None, **kwargs): 

741 """Obtain token for this client (rather than for an end user), 

742 a.k.a. the Client Credentials Grant, used by Backend Applications. 

743 

744 We don't name it obtain_token_by_client_credentials(...) because those 

745 credentials are typically already provided in class constructor, not here. 

746 You can still explicitly provide an optional client_secret parameter, 

747 or you can provide such extra parameters as `default_body` during the 

748 class initialization. 

749 """ 

750 data = kwargs.pop("data", {}) 

751 data.update(scope=scope) 

752 return self._obtain_token("client_credentials", data=data, **kwargs) 

753 

754 def __init__(self, 

755 server_configuration, client_id, 

756 on_obtaining_tokens=lambda event: None, # event is defined in _obtain_token(...) 

757 on_removing_rt=lambda token_item: None, 

758 on_updating_rt=lambda token_item, new_rt: None, 

759 **kwargs): 

760 super(Client, self).__init__(server_configuration, client_id, **kwargs) 

761 self.on_obtaining_tokens = on_obtaining_tokens 

762 self.on_removing_rt = on_removing_rt 

763 self.on_updating_rt = on_updating_rt 

764 

765 def _obtain_token( 

766 self, grant_type, params=None, data=None, 

767 also_save_rt=False, 

768 on_obtaining_tokens=None, 

769 *args, **kwargs): 

770 _data = data.copy() # to prevent side effect 

771 resp = super(Client, self)._obtain_token( 

772 grant_type, params, _data, *args, **kwargs) 

773 if "error" not in resp: 

774 _resp = resp.copy() 

775 RT = "refresh_token" 

776 if grant_type == RT and RT in _resp and not also_save_rt: 

777 # Then we skip it from on_obtaining_tokens(); 

778 # Leave it to self.obtain_token_by_refresh_token() 

779 _resp.pop(RT, None) 

780 if "scope" in _resp: 

781 scope = _resp["scope"].split() # It is conceptually a set, 

782 # but we represent it as a list which can be persisted to JSON 

783 else: 

784 # Note: The scope will generally be absent in authorization grant, 

785 # but our obtain_token_by_authorization_code(...) encourages 

786 # app developer to still explicitly provide a scope here. 

787 scope = _data.get("scope") 

788 (on_obtaining_tokens or self.on_obtaining_tokens)({ 

789 "client_id": self.client_id, 

790 "scope": scope, 

791 "token_endpoint": self.configuration["token_endpoint"], 

792 "grant_type": grant_type, # can be used to know an IdToken-less 

793 # response is for an app or for a user 

794 "response": _resp, "params": params, "data": _data, 

795 }) 

796 return resp 

797 

798 def obtain_token_by_refresh_token(self, token_item, scope=None, 

799 rt_getter=lambda token_item: token_item["refresh_token"], 

800 on_removing_rt=None, 

801 on_updating_rt=None, 

802 **kwargs): 

803 # type: (Union[str, dict], Union[str, list, set, tuple], Callable) -> dict 

804 """This is an overload which will trigger token storage callbacks. 

805 

806 :param token_item: 

807 A refresh token (RT) item, in flexible format. It can be a string, 

808 or a whatever data structure containing RT string and its metadata, 

809 in such case the `rt_getter` callable must be able to 

810 extract the RT string out from the token item data structure. 

811 

812 Either way, this token_item will be passed into other callbacks as-is. 

813 

814 :param scope: If omitted, is treated as equal to the scope originally 

815 granted by the resource owner, 

816 according to https://tools.ietf.org/html/rfc6749#section-6 

817 :param rt_getter: A callable to translate the token_item to a raw RT string 

818 :param on_removing_rt: If absent, fall back to the one defined in initialization 

819 

820 :param on_updating_rt: 

821 Default to None, it will fall back to the one defined in initialization. 

822 This is the most common case. 

823 

824 As a special case, you can pass in a False, 

825 then this function will NOT trigger on_updating_rt() for RT UPDATE, 

826 instead it will allow the RT to be added by on_obtaining_tokens(). 

827 This behavior is useful when you are migrating RTs from elsewhere 

828 into a token storage managed by this library. 

829 """ 

830 resp = super(Client, self).obtain_token_by_refresh_token( 

831 rt_getter(token_item) 

832 if not isinstance(token_item, string_types) else token_item, 

833 scope=scope, 

834 also_save_rt=on_updating_rt is False, 

835 **kwargs) 

836 if resp.get('error') == 'invalid_grant': 

837 (on_removing_rt or self.on_removing_rt)(token_item) # Discard old RT 

838 RT = "refresh_token" 

839 if on_updating_rt is not False and RT in resp: 

840 (on_updating_rt or self.on_updating_rt)(token_item, resp[RT]) 

841 return resp 

842 

843 def obtain_token_by_assertion( 

844 self, assertion, grant_type, scope=None, **kwargs): 

845 # type: (bytes, Union[str, None], Union[str, list, set, tuple]) -> dict 

846 """This method implements Assertion Framework for OAuth2 (RFC 7521). 

847 See details at https://tools.ietf.org/html/rfc7521#section-4.1 

848 

849 :param assertion: 

850 The assertion bytes can be a raw SAML2 assertion, or a JWT assertion. 

851 :param grant_type: 

852 It is typically either the value of :attr:`GRANT_TYPE_SAML2`, 

853 or :attr:`GRANT_TYPE_JWT`, the only two profiles defined in RFC 7521. 

854 :param scope: Optional. It must be a subset of previously granted scopes. 

855 """ 

856 encoder = self.grant_assertion_encoders.get(grant_type, lambda a: a) 

857 data = kwargs.pop("data", {}) 

858 data.update(scope=scope, assertion=encoder(assertion)) 

859 return self._obtain_token(grant_type, data=data, **kwargs) 

860