Skip to content

Commit 2ffbc5b

Browse files
Qup42dopry
authored andcommitted
Add configuration option to accept expired tokens
1 parent a158d0b commit 2ffbc5b

File tree

6 files changed

+203
-23
lines changed

6 files changed

+203
-23
lines changed

docs/settings.rst

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -328,6 +328,12 @@ Default: ``True``
328328
Whether to always prompt the :term:`Resource Owner` (End User) to confirm a logout requested by a
329329
:term:`Client` (Relying Party). If it is disabled the :term:`Resource Owner` (End User) will only be prompted if required by the standard.
330330

331+
OIDC_RP_INITIATED_LOGOUT_ACCEPT_EXPIRED_TOKENS
332+
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
333+
Default: ``True``
334+
335+
Whether expired ID tokens are accepted for RP-Initiated Logout. The Tokens must still be signed by the OP and otherwise valid.
336+
331337
OIDC_ISS_ENDPOINT
332338
~~~~~~~~~~~~~~~~~
333339
Default: ``""``

oauth2_provider/settings.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,7 @@
9090
],
9191
"OIDC_RP_INITIATED_LOGOUT_ENABLED": False,
9292
"OIDC_RP_INITIATED_LOGOUT_ALWAYS_PROMPT": True,
93+
"OIDC_RP_INITIATED_LOGOUT_ACCEPT_EXPIRED_TOKENS": True,
9394
# Special settings that will be evaluated at runtime
9495
"_SCOPES": [],
9596
"_DEFAULT_SCOPES": [],

oauth2_provider/views/oidc.py

Lines changed: 52 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,9 @@
77
from django.utils.decorators import method_decorator
88
from django.views.decorators.csrf import csrf_exempt
99
from django.views.generic import FormView, View
10-
from jwcrypto import jwk
10+
from jwcrypto import jwk, jwt
11+
from jwcrypto.common import JWException
12+
from jwcrypto.jwt import JWTExpired
1113
from oauthlib.common import add_params_to_uri
1214

1315
from ..exceptions import (
@@ -20,7 +22,7 @@
2022
)
2123
from ..forms import ConfirmLogoutForm
2224
from ..http import OAuth2ResponseRedirect
23-
from ..models import get_application_model
25+
from ..models import get_application_model, get_id_token_model
2426
from ..settings import oauth2_settings
2527
from .mixins import OAuthLibMixin, OIDCLogoutOnlyMixin, OIDCOnlyMixin
2628

@@ -142,7 +144,50 @@ def _create_userinfo_response(self, request):
142144
return response
143145

144146

145-
def validate_logout_request(user, id_token_hint, client_id, post_logout_redirect_uri):
147+
def _load_id_token(request, token):
148+
"""
149+
Loads an IDToken given its string representation for use with RP-Initiated Logout.
150+
This method differs from `oauth2_validators._load_id_token` in two ways.
151+
This method validates the `iss` claim and might accept expired but otherwise valid tokens
152+
depending on the configuration.
153+
"""
154+
IDToken = get_id_token_model()
155+
validator = oauth2_settings.OAUTH2_VALIDATOR_CLASS()
156+
157+
key = validator._get_key_for_token(token)
158+
if not key:
159+
return None
160+
161+
try:
162+
if oauth2_settings.OIDC_RP_INITIATED_LOGOUT_ACCEPT_EXPIRED_TOKENS:
163+
# Only check the following while loading the JWT
164+
# - claims are dict
165+
# - the Claims defined in RFC7519 if present have the correct type (string, integer, etc.)
166+
# The claim contents are not validated. `exp` and `nbf` in particular are not validated.
167+
check_claims = {}
168+
else:
169+
# Also validate the `exp` (expiration time) and `nbf` (not before) claims.
170+
check_claims = None
171+
jwt_token = jwt.JWT(key=key, jwt=token, check_claims=check_claims)
172+
claims = json.loads(jwt_token.claims)
173+
174+
# Verification of `iss` claim is mandated by OIDC RP-Initiated Logout specs.
175+
if claims["iss"] != validator.get_oidc_issuer_endpoint(request):
176+
# IDToken was not issued by this OP.
177+
return None
178+
179+
# Assumption: the `sub` claim and `user` property of the corresponding IDToken Object point to the
180+
# same user.
181+
# To verify that the IDToken was intended for the user it is therefore sufficient to check the `user`
182+
# attribute on the IDToken Object later on.
183+
184+
return IDToken.objects.get(jti=claims["jti"])
185+
186+
except (JWException, JWTExpired, IDToken.DoesNotExist):
187+
return None
188+
189+
190+
def validate_logout_request(request, id_token_hint, client_id, post_logout_redirect_uri):
146191
"""
147192
Validate an OIDC RP-Initiated Logout Request.
148193
`(prompt_logout, (post_logout_redirect_uri, application))` is returned.
@@ -156,19 +201,18 @@ def validate_logout_request(user, id_token_hint, client_id, post_logout_redirect
156201
The `id_token_hint` will be validated if given. If both `client_id` and `id_token_hint` are given they
157202
will be validated against each other.
158203
"""
159-
validator = oauth2_settings.OAUTH2_VALIDATOR_CLASS()
160204

161205
id_token = None
162206
must_prompt_logout = True
163207
if id_token_hint:
164208
# Note: The standard states that expired tokens should still be accepted.
165209
# This implementation only accepts tokens that are still valid.
166-
id_token = validator._load_id_token(id_token_hint)
210+
id_token = _load_id_token(request, id_token_hint)
167211

168212
if not id_token:
169213
raise InvalidIDTokenError()
170214

171-
if id_token.user == user:
215+
if id_token.user == request.user:
172216
# A logout without user interaction (i.e. no prompt) is only allowed
173217
# if an ID Token is provided that matches the current user.
174218
must_prompt_logout = False
@@ -232,7 +276,7 @@ def get(self, request, *args, **kwargs):
232276

233277
try:
234278
prompt, (redirect_uri, application) = validate_logout_request(
235-
user=request.user,
279+
request=request,
236280
id_token_hint=id_token_hint,
237281
client_id=client_id,
238282
post_logout_redirect_uri=post_logout_redirect_uri,
@@ -264,7 +308,7 @@ def form_valid(self, form):
264308

265309
try:
266310
prompt, (redirect_uri, application) = validate_logout_request(
267-
user=self.request.user,
311+
request=self.request,
268312
id_token_hint=id_token_hint,
269313
client_id=client_id,
270314
post_logout_redirect_uri=post_logout_redirect_uri,

tests/conftest.py

Lines changed: 51 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,16 @@
1+
import uuid
2+
from datetime import timedelta
13
from types import SimpleNamespace
24
from urllib.parse import parse_qs, urlparse
35

46
import pytest
57
from django.conf import settings as test_settings
68
from django.contrib.auth import get_user_model
79
from django.urls import reverse
8-
from jwcrypto import jwk
10+
from django.utils import dateformat, timezone
11+
from jwcrypto import jwk, jwt
912

10-
from oauth2_provider.models import get_application_model
13+
from oauth2_provider.models import get_application_model, get_id_token_model
1114
from oauth2_provider.settings import oauth2_settings as _oauth2_settings
1215

1316
from . import presets
@@ -196,6 +199,52 @@ def generate_access_token(oauth2_settings, application, test_user, client, setti
196199
)
197200

198201

202+
@pytest.fixture
203+
def expired_id_token(oauth2_settings, oidc_key, test_user, application):
204+
payload = generate_id_token_payload(oauth2_settings, application, oidc_key)
205+
return generate_id_token(test_user, payload, oidc_key, application)
206+
207+
208+
@pytest.fixture
209+
def id_token_wrong_aud(oauth2_settings, oidc_key, test_user, application):
210+
payload = generate_id_token_payload(oauth2_settings, application, oidc_key)
211+
payload[1]["aud"] = ""
212+
return generate_id_token(test_user, payload, oidc_key, application)
213+
214+
215+
@pytest.fixture
216+
def id_token_wrong_iss(oauth2_settings, oidc_key, test_user, application):
217+
payload = generate_id_token_payload(oauth2_settings, application, oidc_key)
218+
payload[1]["iss"] = ""
219+
return generate_id_token(test_user, payload, oidc_key, application)
220+
221+
222+
def generate_id_token_payload(oauth2_settings, application, oidc_key):
223+
# Default leeway of JWT in jwcrypto is 60 seconds. This means that tokens that expired up to 60 seconds
224+
# ago are still accepted.
225+
expiration_time = timezone.now() - timedelta(seconds=61)
226+
# Calculate values for the IDToken
227+
exp = int(dateformat.format(expiration_time, "U"))
228+
jti = str(uuid.uuid4())
229+
aud = application.client_id
230+
iss = oauth2_settings.OIDC_ISS_ENDPOINT
231+
# Construct and sign the IDToken
232+
header = {"typ": "JWT", "alg": "RS256", "kid": oidc_key.thumbprint()}
233+
id_token = {"exp": exp, "jti": jti, "aud": aud, "iss": iss}
234+
return header, id_token, jti, expiration_time
235+
236+
237+
def generate_id_token(user, payload, oidc_key, application):
238+
header, id_token, jti, expiration_time = payload
239+
jwt_token = jwt.JWT(header=header, claims=id_token)
240+
jwt_token.make_signed_token(oidc_key)
241+
# Save the IDToken in the DB. Required for later lookups from e.g. RP-Initiated Logout.
242+
IDToken = get_id_token_model()
243+
IDToken.objects.create(user=user, scope="", expires=expiration_time, jti=jti, application=application)
244+
# Return the token as a string.
245+
return jwt_token.token.serialize(compact=True)
246+
247+
199248
@pytest.fixture
200249
def oidc_tokens(oauth2_settings, application, test_user, client):
201250
return generate_access_token(

tests/presets.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,8 @@
3131
OIDC_SETTINGS_RP_LOGOUT = deepcopy(OIDC_SETTINGS_RW)
3232
OIDC_SETTINGS_RP_LOGOUT["OIDC_RP_INITIATED_LOGOUT_ENABLED"] = True
3333
OIDC_SETTINGS_RP_LOGOUT["OIDC_RP_INITIATED_LOGOUT_ALWAYS_PROMPT"] = False
34+
OIDC_SETTINGS_RP_LOGOUT_DENY_EXPIRED = deepcopy(OIDC_SETTINGS_RP_LOGOUT)
35+
OIDC_SETTINGS_RP_LOGOUT_DENY_EXPIRED["OIDC_RP_INITIATED_LOGOUT_ACCEPT_EXPIRED_TOKENS"] = False
3436
REST_FRAMEWORK_SCOPES = {
3537
"SCOPES": {
3638
"read": "Read scope",

tests/test_oidc_views.py

Lines changed: 91 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,14 @@
11
import pytest
22
from django.contrib.auth import get_user
3-
from django.test import TestCase
3+
from django.contrib.auth.models import AnonymousUser
4+
from django.test import RequestFactory, TestCase
45
from django.urls import reverse
56

67
from oauth2_provider.exceptions import ClientIdMissmatch, InvalidOIDCClientError, InvalidOIDCRedirectURIError
8+
from oauth2_provider.models import get_id_token_model
79
from oauth2_provider.oauth2_validators import OAuth2Validator
810
from oauth2_provider.settings import oauth2_settings
9-
from oauth2_provider.views.oidc import validate_logout_request
11+
from oauth2_provider.views.oidc import _load_id_token, validate_logout_request
1012

1113
from . import presets
1214

@@ -165,6 +167,22 @@ def test_get_jwks_info_multiple_rsa_keys(self):
165167
assert response.json() == expected_response
166168

167169

170+
def mock_request():
171+
"""
172+
Dummy request with an AnonymousUser attached.
173+
"""
174+
return mock_request_for(AnonymousUser())
175+
176+
177+
def mock_request_for(user):
178+
"""
179+
Dummy request with the `user` attached.
180+
"""
181+
request = RequestFactory().get("")
182+
request.user = user
183+
return request
184+
185+
168186
@pytest.mark.django_db
169187
@pytest.mark.parametrize("ALWAYS_PROMPT", [True, False])
170188
def test_validate_logout_request(oidc_tokens, public_application, other_user, rp_settings, ALWAYS_PROMPT):
@@ -174,66 +192,72 @@ def test_validate_logout_request(oidc_tokens, public_application, other_user, rp
174192
client_id = application.client_id
175193
id_token = oidc_tokens.id_token
176194
assert validate_logout_request(
177-
user=oidc_tokens.user, id_token_hint=None, client_id=None, post_logout_redirect_uri=None
195+
request=mock_request_for(oidc_tokens.user),
196+
id_token_hint=None,
197+
client_id=None,
198+
post_logout_redirect_uri=None,
178199
) == (True, (None, None))
179200
assert validate_logout_request(
180-
user=oidc_tokens.user, id_token_hint=None, client_id=client_id, post_logout_redirect_uri=None
201+
request=mock_request_for(oidc_tokens.user),
202+
id_token_hint=None,
203+
client_id=client_id,
204+
post_logout_redirect_uri=None,
181205
) == (True, (None, application))
182206
assert validate_logout_request(
183-
user=oidc_tokens.user,
207+
request=mock_request_for(oidc_tokens.user),
184208
id_token_hint=None,
185209
client_id=client_id,
186210
post_logout_redirect_uri="http://example.org",
187211
) == (True, ("http://example.org", application))
188212
assert validate_logout_request(
189-
user=oidc_tokens.user,
213+
request=mock_request_for(oidc_tokens.user),
190214
id_token_hint=id_token,
191215
client_id=None,
192216
post_logout_redirect_uri="http://example.org",
193217
) == (ALWAYS_PROMPT, ("http://example.org", application))
194218
assert validate_logout_request(
195-
user=other_user,
219+
request=mock_request_for(other_user),
196220
id_token_hint=id_token,
197221
client_id=None,
198222
post_logout_redirect_uri="http://example.org",
199223
) == (True, ("http://example.org", application))
200224
assert validate_logout_request(
201-
user=oidc_tokens.user,
225+
request=mock_request_for(oidc_tokens.user),
202226
id_token_hint=id_token,
203227
client_id=client_id,
204228
post_logout_redirect_uri="http://example.org",
205229
) == (ALWAYS_PROMPT, ("http://example.org", application))
206230
with pytest.raises(ClientIdMissmatch):
207231
validate_logout_request(
208-
user=oidc_tokens.user,
232+
request=mock_request_for(oidc_tokens.user),
209233
id_token_hint=id_token,
210234
client_id=public_application.client_id,
211235
post_logout_redirect_uri="http://other.org",
212236
)
213237
with pytest.raises(InvalidOIDCClientError):
214238
validate_logout_request(
215-
user=oidc_tokens.user,
239+
request=mock_request_for(oidc_tokens.user),
216240
id_token_hint=None,
217241
client_id=None,
218242
post_logout_redirect_uri="http://example.org",
219243
)
220244
with pytest.raises(InvalidOIDCRedirectURIError):
221245
validate_logout_request(
222-
user=oidc_tokens.user,
246+
request=mock_request_for(oidc_tokens.user),
223247
id_token_hint=None,
224248
client_id=client_id,
225249
post_logout_redirect_uri="example.org",
226250
)
227251
with pytest.raises(InvalidOIDCRedirectURIError):
228252
validate_logout_request(
229-
user=oidc_tokens.user,
253+
request=mock_request_for(oidc_tokens.user),
230254
id_token_hint=None,
231255
client_id=client_id,
232256
post_logout_redirect_uri="imap://example.org",
233257
)
234258
with pytest.raises(InvalidOIDCRedirectURIError):
235259
validate_logout_request(
236-
user=oidc_tokens.user,
260+
request=mock_request_for(oidc_tokens.user),
237261
id_token_hint=None,
238262
client_id=client_id,
239263
post_logout_redirect_uri="http://other.org",
@@ -354,6 +378,60 @@ def test_rp_initiated_logout_post_allowed(loggend_in_client, oidc_tokens, rp_set
354378
assert not is_logged_in(loggend_in_client)
355379

356380

381+
@pytest.mark.django_db
382+
@pytest.mark.oauth2_settings(presets.OIDC_SETTINGS_RP_LOGOUT)
383+
def test_rp_initiated_logout_expired_tokens_accept(loggend_in_client, application, expired_id_token):
384+
# Accepting expired (but otherwise valid and signed by us) tokens is enabled. Logout should go through.
385+
rsp = loggend_in_client.get(
386+
reverse("oauth2_provider:rp-initiated-logout"),
387+
data={
388+
"id_token_hint": expired_id_token,
389+
"client_id": application.client_id,
390+
},
391+
)
392+
assert rsp.status_code == 302
393+
assert not is_logged_in(loggend_in_client)
394+
395+
396+
@pytest.mark.django_db
397+
@pytest.mark.oauth2_settings(presets.OIDC_SETTINGS_RP_LOGOUT_DENY_EXPIRED)
398+
def test_rp_initiated_logout_expired_tokens_deny(loggend_in_client, application, expired_id_token):
399+
# Expired tokens should not be accepted by default.
400+
rsp = loggend_in_client.get(
401+
reverse("oauth2_provider:rp-initiated-logout"),
402+
data={
403+
"id_token_hint": expired_id_token,
404+
"client_id": application.client_id,
405+
},
406+
)
407+
assert rsp.status_code == 400
408+
assert is_logged_in(loggend_in_client)
409+
410+
411+
@pytest.mark.django_db
412+
@pytest.mark.oauth2_settings(presets.OIDC_SETTINGS_RP_LOGOUT)
413+
def test_load_id_token_accept_expired(expired_id_token):
414+
assert isinstance(_load_id_token(mock_request(), expired_id_token), get_id_token_model())
415+
416+
417+
@pytest.mark.django_db
418+
@pytest.mark.oauth2_settings(presets.OIDC_SETTINGS_RP_LOGOUT)
419+
def test_load_id_token_wrong_aud(id_token_wrong_aud):
420+
assert _load_id_token(mock_request(), id_token_wrong_aud) is None
421+
422+
423+
@pytest.mark.django_db
424+
@pytest.mark.oauth2_settings(presets.OIDC_SETTINGS_RP_LOGOUT)
425+
def test_load_id_token_wrong_iss(id_token_wrong_iss):
426+
assert _load_id_token(mock_request(), id_token_wrong_iss) is None
427+
428+
429+
@pytest.mark.django_db
430+
@pytest.mark.oauth2_settings(presets.OIDC_SETTINGS_RP_LOGOUT_DENY_EXPIRED)
431+
def test_load_id_token_deny_expired(expired_id_token):
432+
assert _load_id_token(mock_request(), expired_id_token) is None
433+
434+
357435
@pytest.mark.django_db
358436
@pytest.mark.parametrize("method", ["get", "post"])
359437
def test_userinfo_endpoint(oidc_tokens, client, method):

0 commit comments

Comments
 (0)