Skip to content

Commit 8768915

Browse files
committed
Merge branch 'obtain-token-by-auth-code-flow' into dev
2 parents df27e67 + e8be841 commit 8768915

File tree

4 files changed

+244
-27
lines changed

4 files changed

+244
-27
lines changed

oauth2cli/authcode.py

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -210,16 +210,17 @@ def __exit__(self, exc_type, exc_val, exc_tb):
210210
args = parser.parse_args()
211211
client = Client({"authorization_endpoint": args.endpoint}, args.client_id)
212212
with AuthCodeReceiver(port=args.port) as receiver:
213-
auth_uri = client.build_auth_request_uri(
214-
"code",
213+
flow = client.initiate_auth_code_flow(
215214
scope=args.scope.split() if args.scope else None,
216-
redirect_uri="http://{h}:{p}".format(h=args.host, p=receiver.get_port()))
215+
redirect_uri="http://{h}:{p}".format(h=args.host, p=receiver.get_port()),
216+
)
217217
print(json.dumps(receiver.get_auth_response(
218-
auth_uri=auth_uri,
218+
auth_uri=flow["auth_uri"],
219219
welcome_template=
220220
"<a href='$auth_uri'>Sign In</a>, or <a href='$abort_uri'>Abort</a",
221221
error_template="Oh no. $error",
222222
success_template="Oh yeah. Got $code",
223223
timeout=60,
224+
state=flow["state"], # Optional
224225
), indent=4))
225226

oauth2cli/oauth2.py

Lines changed: 143 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@
1313
import base64
1414
import sys
1515
import functools
16+
import random
17+
import string
1618

1719
import requests
1820

@@ -129,6 +131,10 @@ def __init__(
129131
This does not apply if you have chosen to pass your own Http client.
130132
131133
"""
134+
if not server_configuration:
135+
raise ValueError("Missing input parameter server_configuration")
136+
if not client_id:
137+
raise ValueError("Missing input parameter client_id")
132138
self.configuration = server_configuration
133139
self.client_id = client_id
134140
self.client_secret = client_secret
@@ -353,38 +359,142 @@ def obtain_token_by_device_flow(self,
353359
return result
354360
time.sleep(1) # Shorten each round, to make exit more responsive
355361

362+
def _build_auth_request_uri(
363+
self,
364+
response_type, redirect_uri=None, scope=None, state=None, **kwargs):
365+
if "authorization_endpoint" not in self.configuration:
366+
raise ValueError("authorization_endpoint not found in configuration")
367+
authorization_endpoint = self.configuration["authorization_endpoint"]
368+
params = self._build_auth_request_params(
369+
response_type, redirect_uri=redirect_uri, scope=scope, state=state,
370+
**kwargs)
371+
sep = '&' if '?' in authorization_endpoint else '?'
372+
return "%s%s%s" % (authorization_endpoint, sep, urlencode(params))
373+
356374
def build_auth_request_uri(
357375
self,
358376
response_type, redirect_uri=None, scope=None, state=None, **kwargs):
377+
# This method could be named build_authorization_request_uri() instead,
378+
# but then there would be a build_authentication_request_uri() in the OIDC
379+
# subclass doing almost the same thing. So we use a loose term "auth" here.
359380
"""Generate an authorization uri to be visited by resource owner.
360381
382+
Parameters are the same as another method :func:`initiate_auth_code_flow()`,
383+
whose functionality is a superset of this method.
384+
385+
:return: The auth uri as a string.
386+
"""
387+
warnings.warn("Use initiate_auth_code_flow() instead. ", DeprecationWarning)
388+
return self._build_auth_request_uri(
389+
response_type, redirect_uri=redirect_uri, scope=scope, state=state,
390+
**kwargs)
391+
392+
def initiate_auth_code_flow(
393+
# The name is influenced by OIDC
394+
# https://openid.net/specs/openid-connect-core-1_0.html#CodeFlowAuth
395+
self,
396+
scope=None, redirect_uri=None, state=None,
397+
**kwargs):
398+
"""Initiate an auth code flow.
399+
361400
Later when the response reaches your redirect_uri,
362-
you can use parse_auth_response() to check the returned state.
363-
364-
This method could be named build_authorization_request_uri() instead,
365-
but then there would be a build_authentication_request_uri() in the OIDC
366-
subclass doing almost the same thing. So we use a loose term "auth" here.
367-
368-
:param response_type:
369-
Must be "code" when you are using Authorization Code Grant,
370-
"token" when you are using Implicit Grant, or other
371-
(possibly space-delimited) strings as registered extension value.
372-
See https://tools.ietf.org/html/rfc6749#section-3.1.1
373-
:param redirect_uri: Optional. Server will use the pre-registered one.
374-
:param scope: It is a space-delimited, case-sensitive string.
401+
you can use :func:`~obtain_token_by_auth_code_flow()`
402+
to complete the authentication/authorization.
403+
404+
:param list scope:
405+
It is a list of case-sensitive strings.
375406
Some ID provider can accept empty string to represent default scope.
376-
:param state: Recommended. An opaque value used by the client to
407+
:param str redirect_uri:
408+
Optional. If not specified, server will use the pre-registered one.
409+
:param str state:
410+
An opaque value used by the client to
377411
maintain state between the request and callback.
412+
If absent, this library will automatically generate one internally.
378413
:param kwargs: Other parameters, typically defined in OpenID Connect.
414+
415+
:return:
416+
The auth code flow. It is a dict in this form::
417+
418+
{
419+
"auth_uri": "https://...", // Guide user to visit this
420+
"state": "...", // You may choose to verify it by yourself,
421+
// or just let obtain_token_by_auth_code_flow()
422+
// do that for you.
423+
"...": "...", // Everything else are reserved and internal
424+
}
425+
426+
The caller is expected to::
427+
428+
1. somehow store this content, typically inside the current session,
429+
2. guide the end user (i.e. resource owner) to visit that auth_uri,
430+
3. and then relay this dict and subsequent auth response to
431+
:func:`~obtain_token_by_auth_code_flow()`.
379432
"""
380-
if "authorization_endpoint" not in self.configuration:
381-
raise ValueError("authorization_endpoint not found in configuration")
382-
authorization_endpoint = self.configuration["authorization_endpoint"]
383-
params = self._build_auth_request_params(
384-
response_type, redirect_uri=redirect_uri, scope=scope, state=state,
433+
response_type = kwargs.pop("response_type", "code") # Auth Code flow
434+
# Must be "code" when you are using Authorization Code Grant.
435+
# The "token" for Implicit Grant is not applicable thus not allowed.
436+
# It could theoretically be other
437+
# (possibly space-delimited) strings as registered extension value.
438+
# See https://tools.ietf.org/html/rfc6749#section-3.1.1
439+
if "token" in response_type:
440+
# Implicit grant would cause auth response coming back in #fragment,
441+
# but fragment won't reach a web service.
442+
raise ValueError('response_type="token ..." is not allowed')
443+
flow = { # These data are required by obtain_token_by_auth_code_flow()
444+
"state": state or "".join(random.sample(string.ascii_letters, 16)),
445+
"redirect_uri": redirect_uri,
446+
"scope": scope,
447+
}
448+
auth_uri = self._build_auth_request_uri(
449+
response_type, **dict(flow, **kwargs))
450+
flow["auth_uri"] = auth_uri
451+
return flow
452+
453+
def obtain_token_by_auth_code_flow(
454+
self,
455+
auth_code_flow,
456+
auth_response,
457+
scope=None,
458+
**kwargs):
459+
"""With the auth_response being redirected back,
460+
validate it against auth_code_flow, and then obtain tokens.
461+
462+
:param dict auth_code_flow:
463+
The same dict returned by :func:`~initiate_auth_code_flow()`.
464+
:param dict auth_response:
465+
A dict based on query string received from auth server.
466+
:param scope:
467+
You don't usually need to use scope parameter here.
468+
Some Identity Provider allows you to provide
469+
a subset of what you specified during :func:`~initiate_auth_code_flow`.
470+
471+
:return:
472+
* A dict containing "access_token" and/or "id_token", among others,
473+
depends on what scope was used.
474+
(See https://tools.ietf.org/html/rfc6749#section-5.1)
475+
* A dict containing "error", optionally "error_description", "error_uri".
476+
(It is either `this <https://tools.ietf.org/html/rfc6749#section-4.1.2.1>`_
477+
or `that <https://tools.ietf.org/html/rfc6749#section-5.2>`_
478+
"""
479+
if auth_code_flow.get("state") != auth_response.get("state"):
480+
raise ValueError("state mismatch: {} vs {}".format(
481+
auth_code_flow.get("state"), auth_response.get("state")))
482+
if auth_response.get("error"): # It means the first leg encountered error
483+
return auth_response
484+
if scope and set(scope) - set(auth_code_flow.get("scope", [])):
485+
raise ValueError(
486+
"scope must be None or a subset of %s" % auth_code_flow.get("scope"))
487+
assert auth_response.get("code"), "First leg's response should have code"
488+
return self._obtain_token_by_authorization_code(
489+
auth_response["code"],
490+
redirect_uri=auth_code_flow.get("redirect_uri"),
491+
# Required, if "redirect_uri" parameter was included in the
492+
# authorization request, and their values MUST be identical.
493+
scope=scope or auth_code_flow.get("scope"),
494+
# It is both unnecessary and harmless, per RFC 6749.
495+
# We use the same scope already used in auth request uri,
496+
# thus token cache can know what scope the tokens are for.
385497
**kwargs)
386-
sep = '&' if '?' in authorization_endpoint else '?'
387-
return "%s%s%s" % (authorization_endpoint, sep, urlencode(params))
388498

389499
@staticmethod
390500
def parse_auth_response(params, state=None):
@@ -394,6 +504,8 @@ def parse_auth_response(params, state=None):
394504
:param state: REQUIRED if the state parameter was present in the client
395505
authorization request. This function will compare it with response.
396506
"""
507+
warnings.warn(
508+
"Use obtain_token_by_auth_code_flow() instead", DeprecationWarning)
397509
if not isinstance(params, dict):
398510
params = parse_qs(params)
399511
if params.get('state') != state:
@@ -408,6 +520,9 @@ def obtain_token_by_authorization_code(
408520
but it can also be used by a device-side native app (Public Client).
409521
See more detail at https://tools.ietf.org/html/rfc6749#section-4.1.3
410522
523+
You are encouraged to use its higher level method
524+
:func:`~obtain_token_by_auth_code_flow` instead.
525+
411526
:param code: The authorization code received from authorization server.
412527
:param redirect_uri:
413528
Required, if the "redirect_uri" parameter was included in the
@@ -417,6 +532,13 @@ def obtain_token_by_authorization_code(
417532
We suggest to use the same scope already used in auth request uri,
418533
so that this library can link the obtained tokens with their scope.
419534
"""
535+
warnings.warn(
536+
"Use obtain_token_by_auth_code_flow() instead", DeprecationWarning)
537+
return self._obtain_token_by_authorization_code(
538+
code, redirect_uri=redirect_uri, scope=scope, **kwargs)
539+
540+
def _obtain_token_by_authorization_code(
541+
self, code, redirect_uri=None, scope=None, **kwargs):
420542
data = kwargs.pop("data", {})
421543
data.update(code=code, redirect_uri=redirect_uri)
422544
if scope:

oauth2cli/oidc.py

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11
import json
22
import base64
33
import time
4+
import random
5+
import string
6+
import warnings
7+
import hashlib
48

59
from . import oauth2
610

@@ -70,6 +74,11 @@ def decode_id_token(id_token, client_id=None, issuer=None, nonce=None, now=None)
7074
return decoded
7175

7276

77+
def _nonce_hash(nonce):
78+
# https://openid.net/specs/openid-connect-core-1_0.html#NonceNotes
79+
return hashlib.sha256(nonce.encode("ascii")).hexdigest()
80+
81+
7382
class Client(oauth2.Client):
7483
"""OpenID Connect is a layer on top of the OAuth2.
7584
@@ -101,6 +110,7 @@ def build_auth_request_uri(self, response_type, nonce=None, **kwargs):
101110
A hard-to-guess string used to mitigate replay attacks. See also
102111
`OIDC specs <https://openid.net/specs/openid-connect-core-1_0.html#AuthRequest>`_.
103112
"""
113+
warnings.warn("Use initiate_auth_code_flow() instead", DeprecationWarning)
104114
return super(Client, self).build_auth_request_uri(
105115
response_type, nonce=nonce, **kwargs)
106116

@@ -116,6 +126,8 @@ def obtain_token_by_authorization_code(self, code, nonce=None, **kwargs):
116126
same nonce should also be provided here, so that we'll validate it.
117127
An exception will be raised if the nonce in id token mismatches.
118128
"""
129+
warnings.warn(
130+
"Use obtain_token_by_auth_code_flow() instead", DeprecationWarning)
119131
result = super(Client, self).obtain_token_by_authorization_code(
120132
code, **kwargs)
121133
nonce_in_id_token = result.get("id_token_claims", {}).get("nonce")
@@ -125,3 +137,58 @@ def obtain_token_by_authorization_code(self, code, nonce=None, **kwargs):
125137
(nonce_in_id_token, nonce))
126138
return result
127139

140+
def initiate_auth_code_flow(
141+
self,
142+
scope=None,
143+
**kwargs):
144+
"""Initiate an auth code flow.
145+
146+
It provides nonce protection automatically.
147+
148+
:param list scope:
149+
A list of strings, e.g. ["profile", "email", ...].
150+
This method will automatically send ["openid"] to the wire,
151+
although it won't modify your input list.
152+
153+
See :func:`oauth2.Client.initiate_auth_code_flow` in parent class
154+
for descriptions on other parameters and return value.
155+
"""
156+
if "id_token" in kwargs.get("response_type", ""):
157+
# Implicit grant would cause auth response coming back in #fragment,
158+
# but fragment won't reach a web service.
159+
raise ValueError('response_type="id_token ..." is not allowed')
160+
_scope = list(scope) if scope else [] # We won't modify input parameter
161+
if "openid" not in _scope:
162+
# "If no openid scope value is present,
163+
# the request may still be a valid OAuth 2.0 request,
164+
# but is not an OpenID Connect request." -- OIDC Core Specs, 3.1.2.2
165+
# https://openid.net/specs/openid-connect-core-1_0.html#AuthRequestValidation
166+
# Here we just automatically add it. If the caller do not want id_token,
167+
# they should simply go with oauth2.Client.
168+
_scope.append("openid")
169+
nonce = "".join(random.sample(string.ascii_letters, 16))
170+
flow = super(Client, self).initiate_auth_code_flow(
171+
scope=_scope, nonce=_nonce_hash(nonce), **kwargs)
172+
flow["nonce"] = nonce
173+
return flow
174+
175+
def obtain_token_by_auth_code_flow(self, auth_code_flow, auth_response, **kwargs):
176+
"""Validate the auth_response being redirected back, and then obtain tokens.
177+
and obtain ID token which can be used for user sign in.
178+
179+
It provides nonce protection out-of-the-box.
180+
181+
See :func:`oauth2.Client.obtain_token_by_auth_code_flow` in parent class
182+
for descriptions on other parameters and return value.
183+
"""
184+
result = super(Client, self).obtain_token_by_auth_code_flow(
185+
auth_code_flow, auth_response, **kwargs)
186+
if "id_token_claims" in result:
187+
nonce_in_id_token = result.get("id_token_claims", {}).get("nonce")
188+
expected_hash = _nonce_hash(auth_code_flow["nonce"])
189+
if nonce_in_id_token != expected_hash:
190+
raise RuntimeError(
191+
'The nonce in id token ("%s") should match our nonce ("%s")' %
192+
(nonce_in_id_token, expected_hash))
193+
return result
194+

tests/test_client.py

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
import requests
1111

1212
from oauth2cli.oidc import Client
13-
from oauth2cli.authcode import obtain_auth_code
13+
from oauth2cli.authcode import obtain_auth_code, AuthCodeReceiver
1414
from oauth2cli.assertion import JwtSigner
1515
from tests import unittest, Oauth2TestCase
1616
from tests.http_client import MinimalHttpClient, MinimalResponse
@@ -153,6 +153,33 @@ def test_auth_code(self):
153153
redirect_uri=redirect_uri)
154154
self.assertLoosely(result, lambda: self.assertIn('access_token', result))
155155

156+
@unittest.skipUnless(
157+
"authorization_endpoint" in CONFIG.get("openid_configuration", {}),
158+
"authorization_endpoint missing")
159+
def test_auth_code_flow(self):
160+
with AuthCodeReceiver(port=CONFIG.get("listen_port")) as receiver:
161+
flow = self.client.initiate_auth_code_flow(
162+
redirect_uri="http://localhost:%d" % receiver.get_port(),
163+
scope=CONFIG.get("scope"),
164+
)
165+
auth_response = receiver.get_auth_response(
166+
auth_uri=flow["auth_uri"],
167+
state=flow["state"], # Optional but recommended
168+
timeout=120,
169+
welcome_template="""<html><body>
170+
authorization_endpoint = {a}, client_id = {i}
171+
<a href="$auth_uri">Sign In</a> or <a href="$abort_uri">Abort</a>
172+
</body></html>""".format(
173+
a=CONFIG["openid_configuration"]["authorization_endpoint"],
174+
i=CONFIG.get("client_id")),
175+
)
176+
self.assertIsNotNone(
177+
auth_response.get("code"), "Error: {}, Detail: {}".format(
178+
auth_response.get("error"), auth_response))
179+
result = self.client.obtain_token_by_auth_code_flow(flow, auth_response)
180+
#TBD: data={"resource": CONFIG.get("resource")}, # MSFT AAD v1 only
181+
self.assertLoosely(result, lambda: self.assertIn('access_token', result))
182+
156183
@unittest.skipUnless(
157184
CONFIG.get("openid_configuration", {}).get("device_authorization_endpoint"),
158185
"device_authorization_endpoint is missing")
@@ -223,7 +250,7 @@ def test_rt_being_migrated(self):
223250

224251
class TestSessionAccessibility(unittest.TestCase):
225252
def test_accessing_session_property_for_backward_compatibility(self):
226-
client = Client({}, "client_id")
253+
client = Client({"token_endpoint": "https://example.com"}, "client_id")
227254
client.session
228255
client.session.close()
229256
client.session = "something"

0 commit comments

Comments
 (0)