Skip to content

Commit 8dc3bbc

Browse files
committed
Merge branch 'assertion' into dev
2 parents 42965f1 + db834da commit 8dc3bbc

File tree

4 files changed

+130
-4
lines changed

4 files changed

+130
-4
lines changed

oauth2cli/assertion.py

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
import time
2+
import binascii
3+
import base64
4+
import uuid
5+
import logging
6+
7+
import jwt
8+
9+
10+
logger = logging.getLogger(__file__)
11+
12+
class Signer(object):
13+
def sign_assertion(
14+
self, audience, issuer, subject, expires_at,
15+
issued_at=None, assertion_id=None, **kwargs):
16+
# Names are defined in https://tools.ietf.org/html/rfc7521#section-5
17+
raise NotImplementedError("Will be implemented by sub-class")
18+
19+
20+
class JwtSigner(Signer):
21+
def __init__(self, key, algorithm, sha1_thumbprint=None, headers=None):
22+
"""Create a signer.
23+
24+
Args:
25+
26+
key (str): The key for signing, e.g. a base64 encoded private key.
27+
algorithm (str):
28+
"RS256", etc.. See https://pyjwt.readthedocs.io/en/latest/algorithms.html
29+
RSA and ECDSA algorithms require "pip install cryptography".
30+
sha1_thumbprint (str): The x5t aka X.509 certificate SHA-1 thumbprint.
31+
headers (dict): Additional headers, e.g. "kid" or "x5c" etc.
32+
"""
33+
self.key = key
34+
self.algorithm = algorithm
35+
self.headers = headers or {}
36+
if sha1_thumbprint: # https://tools.ietf.org/html/rfc7515#section-4.1.7
37+
self.headers["x5t"] = base64.urlsafe_b64encode(
38+
binascii.a2b_hex(sha1_thumbprint)).decode()
39+
40+
def sign_assertion(
41+
self, audience, issuer, subject=None, expires_at=None,
42+
issued_at=None, assertion_id=None, not_before=None,
43+
additional_claims=None, **kwargs):
44+
"""Sign a JWT Assertion.
45+
46+
Parameters are defined in https://tools.ietf.org/html/rfc7523#section-3
47+
Key-value pairs in additional_claims will be added into payload as-is.
48+
"""
49+
now = time.time()
50+
payload = {
51+
'aud': audience,
52+
'iss': issuer,
53+
'sub': subject or issuer,
54+
'exp': expires_at or (now + 10*60), # 10 minutes
55+
'iat': issued_at or now,
56+
'jti': assertion_id or str(uuid.uuid4()),
57+
}
58+
if not_before:
59+
payload['nbf'] = not_before
60+
payload.update(additional_claims or {})
61+
try:
62+
return jwt.encode(
63+
payload, self.key, algorithm=self.algorithm, headers=self.headers)
64+
except:
65+
if self.algorithm.startswith("RS") or self.algorithm.starswith("ES"):
66+
logger.exception(
67+
'Some algorithms requires "pip install cryptography". '
68+
'See https://pyjwt.readthedocs.io/en/latest/installation.html#cryptographic-dependencies-optional')
69+
raise
70+

oauth2cli/oauth2.py

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@ def __init__(
2222
self,
2323
client_id,
2424
client_secret=None, # Triggers HTTP AUTH for Confidential Client
25+
client_assertion=None, # Assertion for Client Authentication
26+
client_assertion_type=None, # The format of the client_assertion
2527
default_body=None, # a dict to be sent in each token request,
2628
# usually contains Confidential Client authentication parameters
2729
# such as {'client_id': 'your_id', 'client_secret': 'secret'}
@@ -31,6 +33,13 @@ def __init__(
3133
"""Initialize a client object to talk all the OAuth2 grants to the server.
3234
3335
Args:
36+
client_assertion (str):
37+
The client assertion to authenticate this client, per RFC 7521.
38+
client_assertion_type (str):
39+
If you leave it as the default None, this method will try to make
40+
a guess between SAML2 (RFC 7522) and JWT (RFC 7523),
41+
the only two profiles defined in RFC 7521.
42+
But you can also explicitly provide a value, if needed.
3443
configuration (dict):
3544
It contains the configuration (i.e. metadata) of the auth server.
3645
The actual content typically contains keys like
@@ -44,6 +53,13 @@ def __init__(
4453
self.client_id = client_id
4554
self.client_secret = client_secret
4655
self.default_body = default_body or {}
56+
if client_assertion is not None: # See https://tools.ietf.org/html/rfc7521#section-4.2
57+
TYPE_JWT = "urn:ietf:params:oauth:client-assertion-type:jwt-bearer"
58+
TYPE_SAML2 = "urn:ietf:params:oauth:client-assertion-type:saml2-bearer"
59+
if client_assertion_type is None: # RFC7521 defines only 2 profiles
60+
client_assertion_type = TYPE_JWT if "." in client_assertion else TYPE_SAML2
61+
self.default_body["client_assertion"] = client_assertion
62+
self.default_body["client_assertion_type"] = client_assertion_type
4763
self.configuration = configuration or {}
4864
self.logger = logging.getLogger(__name__)
4965

@@ -132,6 +148,8 @@ class Client(BaseClient): # We choose to implement all 4 grants in 1 class
132148
"DEVICE_CODE": "device_code",
133149
}
134150
DEVICE_FLOW_RETRIABLE_ERRORS = ("authorization_pending", "slow_down")
151+
GRANT_TYPE_SAML2 = "urn:ietf:params:oauth:grant-type:saml2-bearer" # RFC7522
152+
GRANT_TYPE_JWT = "urn:ietf:params:oauth:grant-type:jwt-bearer" # RFC7523
135153

136154
def initiate_device_flow(self, scope=None, **kwargs):
137155
# type: (list, **dict) -> dict
@@ -348,3 +366,23 @@ def obtain_token_with_refresh_token(self, token_item, scope=None,
348366
return resp
349367
raise ValueError("token_item should not be a type %s" % type(token_item))
350368

369+
def obtain_token_with_assertion(
370+
self, assertion, grant_type=None, scope=None, **kwargs):
371+
# type: (str, Union[str, None], Union[str, list, set, tuple]) -> dict
372+
"""This method implements Assertion Framework for OAuth2 (RFC 7521).
373+
See details at https://tools.ietf.org/html/rfc7521#section-4.1
374+
375+
:param assertion: The assertion string which will be sent on wire as-is
376+
:param grant_type:
377+
If you leave it as the default None, this method will try to make
378+
a guess between SAML2 (RFC 7522) and JWT (RFC 7523),
379+
the only two profiles defined in RFC 7521.
380+
But you can also explicitly provide a value, if needed.
381+
:param scope: Optional. It must be a subset of previously granted scopes.
382+
"""
383+
if grant_type is None:
384+
grant_type = self.GRANT_TYPE_JWT if "." in assertion else self.GRANT_TYPE_SAML2
385+
data = kwargs.pop("data", {})
386+
data.update(scope=scope, assertion=assertion)
387+
return self._obtain_token(grant_type, data=data, **kwargs)
388+

setup.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,5 +31,6 @@
3131
packages=['oauth2cli'],
3232
install_requires=[
3333
'requests>=2.0.0',
34+
'PyJWT>=1.0.0',
3435
]
3536
)

tests/test_client.py

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111

1212
from oauth2cli.oauth2 import Client
1313
from oauth2cli.authcode import obtain_auth_code
14+
from oauth2cli.assertion import JwtSigner
1415
from tests import unittest
1516

1617

@@ -90,10 +91,26 @@ class TestClient(Oauth2TestCase):
9091

9192
@classmethod
9293
def setUpClass(cls):
93-
cls.client = Client(
94-
CONFIG['client_id'],
95-
client_secret=CONFIG.get('client_secret'),
96-
configuration=CONFIG["openid_configuration"])
94+
if "client_certificate" in CONFIG:
95+
private_key_path = CONFIG["client_certificate"]["private_key_path"]
96+
with open(os.path.join(THIS_FOLDER, private_key_path)) as f:
97+
private_key = f.read() # Expecting PEM format
98+
cls.client = Client(
99+
CONFIG['client_id'],
100+
client_assertion=JwtSigner(
101+
private_key,
102+
algorithm="RS256",
103+
sha1_thumbprint=CONFIG["client_certificate"]["thumbprint"]
104+
).sign_assertion(
105+
audience=CONFIG["openid_configuration"]["token_endpoint"],
106+
issuer=CONFIG["client_id"],
107+
),
108+
configuration=CONFIG["openid_configuration"])
109+
else:
110+
cls.client = Client(
111+
CONFIG['client_id'],
112+
client_secret=CONFIG.get('client_secret'),
113+
configuration=CONFIG["openid_configuration"])
97114

98115
@unittest.skipUnless("client_secret" in CONFIG, "client_secret missing")
99116
def test_client_credentials(self):

0 commit comments

Comments
 (0)