0001-auth_oidc-support-signed-authz-requests-through-jwt-.patch
src/authentic2_auth_oidc/migrations/0008_auto_20200722_1452.py | ||
---|---|---|
1 |
# -*- coding: utf-8 -*- |
|
2 |
# Generated by Django 1.11.18 on 2020-07-22 12:52 |
|
3 |
from __future__ import unicode_literals |
|
4 | ||
5 |
import authentic2_auth_oidc.models |
|
6 |
import django.contrib.postgres.fields.jsonb |
|
7 |
from django.db import migrations, models |
|
8 | ||
9 | ||
10 |
class Migration(migrations.Migration): |
|
11 | ||
12 |
dependencies = [ |
|
13 |
('authentic2_auth_oidc', '0007_auto_20200317_1732'), |
|
14 |
] |
|
15 | ||
16 |
operations = [ |
|
17 |
migrations.AddField( |
|
18 |
model_name='oidcprovider', |
|
19 |
name='request_signature_jwkset_json', |
|
20 |
field=django.contrib.postgres.fields.jsonb.JSONField(blank=True, null=True, validators=[authentic2_auth_oidc.models.validate_jwkset], verbose_name='JSON WebKeyset used for outgoing signatures'), |
|
21 |
), |
|
22 |
migrations.AddField( |
|
23 |
model_name='oidcprovider', |
|
24 |
name='request_signature_supported', |
|
25 |
field=models.BooleanField(default=False, verbose_name='Request signature supported'), |
|
26 |
), |
|
27 |
] |
src/authentic2_auth_oidc/models.py | ||
---|---|---|
121 | 121 |
claims_parameter_supported = models.BooleanField( |
122 | 122 |
verbose_name=_('Claims parameter supported'), |
123 | 123 |
default=False) |
124 |
request_signature_supported = models.BooleanField( |
|
125 |
verbose_name=_('Request signature supported'), |
|
126 |
default=False) |
|
127 |
request_signature_jwkset_json = JSONField( |
|
128 |
verbose_name=_('JSON WebKeyset used for outgoing signatures'), |
|
129 |
null=True, |
|
130 |
blank=True, |
|
131 |
validators=[validate_jwkset]) |
|
124 | 132 | |
125 | 133 |
# ou where new users should be created |
126 | 134 |
strategy = models.CharField( |
... | ... | |
160 | 168 |
return JWKSet.from_json(json.dumps(self.jwkset_json)) |
161 | 169 |
return None |
162 | 170 | |
171 |
@property |
|
172 |
def request_signature_jwkset(self): |
|
173 |
if self.request_signature_jwkset_json: |
|
174 |
return JWKSet.from_json(json.dumps( |
|
175 |
self.request_signature_jwkset_json)) |
|
176 |
return None |
|
177 | ||
178 | ||
163 | 179 |
def __str__(self): |
164 | 180 |
return self.name |
165 | 181 |
src/authentic2_auth_oidc/views.py | ||
---|---|---|
14 | 14 |
# You should have received a copy of the GNU Affero General Public License |
15 | 15 |
# along with this program. If not, see <http://www.gnu.org/licenses/>. |
16 | 16 | |
17 |
import datetime |
|
17 | 18 |
import uuid |
18 | 19 |
import logging |
19 | 20 |
import json |
20 | 21 | |
21 | 22 |
import requests |
22 | 23 | |
24 |
from jwcrypto.common import JWException |
|
25 |
from jwcrypto.jwt import JWT |
|
26 | ||
23 | 27 |
from django.urls import reverse |
24 | 28 |
from django.utils.translation import get_language, ugettext as _ |
25 | 29 |
from django.contrib import messages |
... | ... | |
142 | 146 |
messages.warning(request, _('Login with OpenIDConnect failed, report %s to an ' |
143 | 147 |
'administrator') % request.request_id) |
144 | 148 |
return self.continue_to_next_url() |
145 |
if not code:
|
|
146 |
messages.warning(request, _('Missing code, report %s to an administrator') %
|
|
147 |
request.request_id)
|
|
148 |
logger.warning('auth_oidc: missing code, %r', request.GET)
|
|
149 |
return self.continue_to_next_url()
|
|
150 |
try:
|
|
149 |
if not provider.request_signature_supported:
|
|
150 |
if not code:
|
|
151 |
messages.warning(request, _('Missing code, report %s to an administrator') %
|
|
152 |
request.request_id)
|
|
153 |
logger.warning('auth_oidc: missing code, %r', request.GET)
|
|
154 |
return self.continue_to_next_url()
|
|
151 | 155 |
token_endpoint_request = { |
152 | 156 |
'grant_type': 'authorization_code', |
153 | 157 |
'code': code, |
154 | 158 |
'redirect_uri': request.build_absolute_uri(request.path), |
155 | 159 |
} |
160 |
elif code: |
|
161 |
logger.warning('authz code provided but grant type is signed ' |
|
162 |
'JWT. authz code \'%s\' will be ignored' % code) |
|
163 |
messages.warning(request, _('Provider %s configured for signed JWT grant but an ' |
|
164 |
'authorization code was provided') % provider.issuer) |
|
165 |
return self.continue_to_next_url() |
|
166 | ||
167 |
else: |
|
168 |
# JWT Bearer authz through OAuth assertion framework - see RFC 7523 |
|
169 |
sign_key = None |
|
170 |
for key in provider.request_signature_jwkset: |
|
171 |
if key.key_type in ['EC', 'RSA', 'HMAC']: |
|
172 |
sign_key = key |
|
173 |
break |
|
174 | ||
175 |
if not sign_key: |
|
176 |
messages.warning(request, _('Provider %s configured for signed JWT grant but no ' |
|
177 |
'signature key could be retrieved.') % provider.issuer) |
|
178 |
logger.warning( |
|
179 |
'auth_oidc: provider %s has no jwt grant signature key' % provider.issuer) |
|
180 |
return self.continue_to_next_url() |
|
181 | ||
182 |
header = { |
|
183 |
# FIXME do not hard-code key length |
|
184 |
'alg': {'EC': 'ES256', 'RSA': 'RS256', 'HMAC': 'HS256'}.get(sign_key.key_type), |
|
185 |
'typ': 'authz JWT', |
|
186 |
'cty': 'JWT', |
|
187 |
'kid': sign_key.key_id, |
|
188 |
} |
|
189 |
now = datetime.datetime.now() |
|
190 |
exp = now + datetime.timedelta(hours=1) |
|
191 |
claims = { |
|
192 |
'iss': 'client %s' % provider.client_id, |
|
193 |
'sub': '', # resource owner is not know yet |
|
194 |
'aud': 'provider %s' % provider.issuer, |
|
195 |
'iat': int(now.timestamp()), |
|
196 |
'exp': int(exp.timestamp()), |
|
197 |
} |
|
198 |
jwt = JWT(header=header, claims=claims) |
|
199 |
try: |
|
200 |
jwt.make_signed_token(key=sign_key) |
|
201 |
jwt = jwt.serialize() |
|
202 |
except JWException as e: |
|
203 |
logger.error('error during jwt grant serialization: %s' % e) |
|
204 |
messages.warning( |
|
205 |
request, |
|
206 |
_('Error during grant request issuance, report %s to an administrator') % |
|
207 |
request.request_id) |
|
208 |
return self.continue_to_next_url() |
|
209 |
token_endpoint_request = { |
|
210 |
'grant_type': 'urn:ietf:params:oauth:grant-type:jwt-bearer', |
|
211 |
'assertion': jwt, |
|
212 |
'scope': provider.scopes, |
|
213 |
} |
|
214 |
try: |
|
156 | 215 |
logger.debug('auth_oidc: sent request to token endpoint %r', token_endpoint_request) |
157 | 216 |
response = requests.post(provider.token_endpoint, data=token_endpoint_request, |
158 | 217 |
auth=(provider.client_id, provider.client_secret), timeout=10) |
tests/test_auth_oidc.py | ||
---|---|---|
117 | 117 |
jwkset.add(key_ec) |
118 | 118 |
return jwkset |
119 | 119 | |
120 | ||
121 |
@pytest.fixture |
|
122 |
def request_signature_jwkset_json(): |
|
123 |
key_rsa = JWK.generate(kty='RSA', size=512, kid=KID_RSA) |
|
124 |
key_ec = JWK.generate(kty='EC', size=256, kid=KID_EC) |
|
125 |
jwkset = JWKSet() |
|
126 |
jwkset.add(key_rsa) |
|
127 |
jwkset.add(key_ec) |
|
128 |
return json.loads(jwkset.export(private_keys=True)) |
|
129 | ||
130 | ||
120 | 131 |
OIDC_PROVIDER_PARAMS = [ |
121 | 132 |
{}, |
122 | 133 |
{ |
... | ... | |
246 | 257 | |
247 | 258 |
@urlmatch(netloc=token_endpoint.netloc, path=token_endpoint.path) |
248 | 259 |
def token_endpoint_mock(url, request): |
249 |
if urlparse.parse_qs(request.body).get('code') == [code]: |
|
250 |
exp = now() + datetime.timedelta(seconds=10) |
|
251 |
id_token = { |
|
252 |
'iss': oidc_provider.issuer, |
|
253 |
'sub': sub, |
|
254 |
'iat': int(now().timestamp()), |
|
255 |
'aud': str(oidc_provider.client_id), |
|
256 |
'exp': int(exp.timestamp()), |
|
257 |
'name': 'doe', |
|
258 |
} |
|
259 |
if nonce: |
|
260 |
id_token['nonce'] = nonce |
|
261 |
if extra_id_token: |
|
262 |
id_token.update(extra_id_token) |
|
263 | ||
264 |
if oidc_provider.idtoken_algo in (OIDCProvider.ALGO_RSA, |
|
265 |
OIDCProvider.ALGO_EC): |
|
266 |
alg = { |
|
267 |
OIDCProvider.ALGO_RSA: 'RS256', |
|
268 |
OIDCProvider.ALGO_EC: 'ES256', |
|
269 |
}.get(oidc_provider.idtoken_algo) |
|
270 |
jwk = None |
|
271 |
for key in oidc_provider_jwkset['keys']: |
|
272 |
if key.key_type == { |
|
273 |
OIDCProvider.ALGO_RSA: 'RSA', |
|
274 |
OIDCProvider.ALGO_EC: 'EC', |
|
275 |
}.get(oidc_provider.idtoken_algo): |
|
276 |
jwk = key |
|
277 |
break |
|
278 |
if provides_kid_header: |
|
279 |
header = {'alg': alg, 'kid': kid} |
|
280 |
else: |
|
281 |
header = {'alg': alg, 'kid': jwk.key_id} |
|
282 |
jwt = JWT(header=header, claims=id_token) |
|
283 |
jwt.make_signed_token(jwk) |
|
284 |
else: # hmac |
|
285 |
jwt = JWT(header={'alg': 'HS256'}, |
|
286 |
claims=id_token) |
|
287 |
k = base64url_encode(oidc_provider.client_secret.encode('utf-8')) |
|
288 |
jwt.make_signed_token( |
|
289 |
JWK(kty='oct', |
|
290 |
k=force_text(k))) |
|
291 | ||
292 |
content = { |
|
293 |
'access_token': '1234', |
|
294 |
# check token_type is case insensitive |
|
295 |
'token_type': random.choice(['B', 'b']) + 'earer', |
|
296 |
'id_token': jwt.serialize(), |
|
297 |
} |
|
298 |
return { |
|
299 |
'content': json.dumps(content), |
|
300 |
'headers': { |
|
301 |
'content-type': 'application/json', |
|
302 |
}, |
|
303 |
'status_code': 200, |
|
304 |
} |
|
305 |
else: |
|
260 |
if oidc_provider.request_signature_supported: |
|
261 |
parsed = urlparse.parse_qs(request.body) |
|
262 |
assert len(parsed.get('assertion')) == 1 |
|
263 |
assert parsed.get('grant_type') == ['urn:ietf:params:oauth:grant-type:jwt-bearer'] |
|
264 |
assertion = parsed.get('assertion')[0] |
|
265 |
assert len(assertion.split('.')) == 3 # header, payload, signature |
|
266 | ||
267 |
# JWT deserialization: |
|
268 |
jwt = JWT() |
|
269 |
jwt.deserialize(jwt=assertion, key=oidc_provider.request_signature_jwkset) |
|
270 |
# todo check no claim are missing claim |
|
271 |
elif urlparse.parse_qs(request.body).get('code') != [code]: |
|
306 | 272 |
return { |
307 | 273 |
'content': json.dumps({'error': 'invalid request'}), |
308 | 274 |
'headers': { |
... | ... | |
310 | 276 |
}, |
311 | 277 |
'status_code': 400, |
312 | 278 |
} |
279 |
exp = now() + datetime.timedelta(seconds=10) |
|
280 |
id_token = { |
|
281 |
'iss': oidc_provider.issuer, |
|
282 |
'sub': sub, |
|
283 |
'iat': int(now().timestamp()), |
|
284 |
'aud': str(oidc_provider.client_id), |
|
285 |
'exp': int(exp.timestamp()), |
|
286 |
'name': 'doe', |
|
287 |
} |
|
288 |
if nonce: |
|
289 |
id_token['nonce'] = nonce |
|
290 |
if extra_id_token: |
|
291 |
id_token.update(extra_id_token) |
|
292 | ||
293 |
if oidc_provider.idtoken_algo in (OIDCProvider.ALGO_RSA, |
|
294 |
OIDCProvider.ALGO_EC): |
|
295 |
alg = { |
|
296 |
OIDCProvider.ALGO_RSA: 'RS256', |
|
297 |
OIDCProvider.ALGO_EC: 'ES256', |
|
298 |
}.get(oidc_provider.idtoken_algo) |
|
299 |
jwk = None |
|
300 |
for key in oidc_provider_jwkset['keys']: |
|
301 |
if key.key_type == { |
|
302 |
OIDCProvider.ALGO_RSA: 'RSA', |
|
303 |
OIDCProvider.ALGO_EC: 'EC', |
|
304 |
}.get(oidc_provider.idtoken_algo): |
|
305 |
jwk = key |
|
306 |
break |
|
307 |
if provides_kid_header: |
|
308 |
header = {'alg': alg, 'kid': kid} |
|
309 |
else: |
|
310 |
header = {'alg': alg, 'kid': jwk.key_id} |
|
311 |
jwt = JWT(header=header, claims=id_token) |
|
312 |
jwt.make_signed_token(jwk) |
|
313 |
else: # hmac |
|
314 |
jwt = JWT(header={'alg': 'HS256'}, |
|
315 |
claims=id_token) |
|
316 |
k = base64url_encode(oidc_provider.client_secret.encode('utf-8')) |
|
317 |
jwt.make_signed_token( |
|
318 |
JWK(kty='oct', |
|
319 |
k=force_text(k))) |
|
320 | ||
321 |
content = { |
|
322 |
'access_token': '1234', |
|
323 |
# check token_type is case insensitive |
|
324 |
'token_type': random.choice(['B', 'b']) + 'earer', |
|
325 |
'id_token': jwt.serialize(), |
|
326 |
} |
|
327 |
return { |
|
328 |
'content': json.dumps(content), |
|
329 |
'headers': { |
|
330 |
'content-type': 'application/json', |
|
331 |
}, |
|
332 |
'status_code': 200, |
|
333 |
} |
|
313 | 334 | |
314 | 335 |
@urlmatch(netloc=userinfo_endpoint.netloc, path=userinfo_endpoint.path) |
315 | 336 |
def user_info_endpoint_mock(url, request): |
... | ... | |
482 | 503 |
assert response['Location'] == '/accounts/oidc/login/%s/' % oidc_provider.pk |
483 | 504 | |
484 | 505 | |
485 | ||
486 | 506 |
def test_sso(app, caplog, code, oidc_provider, oidc_provider_jwkset, hooks): |
487 | 507 |
OU = get_ou_model() |
488 | 508 |
cassis = OU.objects.create(name='Cassis', slug='cassis') |
... | ... | |
598 | 618 |
assert response.location.startswith('https://server.example.com/logout?') |
599 | 619 | |
600 | 620 | |
621 |
def test_jwt_bearer_authz_grant(app, caplog, code, oidc_provider, oidc_provider_jwkset, request_signature_jwkset_json, hooks): |
|
622 |
OU = get_ou_model() |
|
623 |
oidc_provider.request_signature_supported = True |
|
624 |
oidc_provider.request_signature_jwkset_json = request_signature_jwkset_json |
|
625 |
oidc_provider.save() |
|
626 |
cassis = OU.objects.create(name='Cassis', slug='cassis') |
|
627 | ||
628 |
response = app.get('/admin/').maybe_follow() |
|
629 |
assert oidc_provider.name in response.text |
|
630 |
response = response.click(oidc_provider.name) |
|
631 |
location = urlparse.urlparse(response.location) |
|
632 |
endpoint = urlparse.urlparse(oidc_provider.authorization_endpoint) |
|
633 |
assert location.scheme == endpoint.scheme |
|
634 |
assert location.netloc == endpoint.netloc |
|
635 |
assert location.path == endpoint.path |
|
636 |
User = get_user_model() |
|
637 |
assert User.objects.count() == 0 |
|
638 | ||
639 |
query = check_simple_qs(urlparse.parse_qs(location.query)) |
|
640 |
assert query['state'] in app.session['auth_oidc'] |
|
641 |
assert query['response_type'] == 'code' |
|
642 |
assert query['client_id'] == str(oidc_provider.client_id) |
|
643 |
assert query['scope'] == 'openid' |
|
644 |
assert query['redirect_uri'] == 'http://testserver' + reverse('oidc-login-callback') |
|
645 | ||
646 |
nonce = app.session['auth_oidc'][query['state']]['request']['nonce'] |
|
647 | ||
648 |
if oidc_provider.claims_parameter_supported: |
|
649 |
claims = json.loads(query['claims']) |
|
650 |
assert claims['id_token']['sub'] is None |
|
651 |
assert claims['userinfo']['email']['essential'] |
|
652 |
assert claims['userinfo']['given_name']['essential'] |
|
653 |
assert claims['userinfo']['family_name']['essential'] |
|
654 |
assert claims['userinfo']['ou'] is None |
|
655 | ||
656 |
with utils.check_log(caplog, 'authz code provided but grant type is signed JWT'): |
|
657 |
with oidc_provider_mock(oidc_provider, oidc_provider_jwkset, code, nonce=nonce): |
|
658 |
response = app.get(login_callback_url(oidc_provider), params={'code': code, 'state': query['state']}) |
|
659 |
assert len(hooks.auth_oidc_backend_modify_user) == 0 |
|
660 | ||
661 |
with oidc_provider_mock(oidc_provider, oidc_provider_jwkset, '', nonce=nonce): |
|
662 |
response = app.get(login_callback_url(oidc_provider), {'state': query['state']}) |
|
663 |
assert len(hooks.auth_oidc_backend_modify_user) == 1 |
|
664 |
assert set(hooks.auth_oidc_backend_modify_user[0]['kwargs']) >= set( |
|
665 |
['user', 'provider', 'user_info', 'id_token', 'access_token']) |
|
666 |
assert urlparse.urlparse(response['Location']).path == '/admin/' |
|
667 |
assert User.objects.count() == 1 |
|
668 |
user = User.objects.get() |
|
669 |
assert user.ou == get_default_ou() |
|
670 |
assert user.username == 'john.doe' |
|
671 |
assert user.first_name == 'John' |
|
672 |
assert user.last_name == 'Doe' |
|
673 |
assert user.email == 'john.doe@example.com' |
|
674 |
assert user.attributes.first_name == 'John' |
|
675 |
assert user.attributes.last_name == 'Doe' |
|
676 |
assert AttributeValue.objects.filter(content='John', verified=True).count() == 1 |
|
677 |
assert AttributeValue.objects.filter(content='Doe', verified=False).count() == 1 |
|
678 |
assert last_authentication_event(session=app.session)['nonce'] == nonce |
|
679 | ||
680 | ||
601 | 681 |
def test_show_on_login_page(app, oidc_provider): |
602 | 682 |
response = app.get('/login/') |
603 | 683 |
assert 'oidc-a-oididp' in response.text |
604 |
- |