Skip to content

Commit fe193db

Browse files
author
Rebecka Gulliksson
committed
Add support for user logout.
User logout will remove all access tokens and authorization codes associated with the given subject identifier.
1 parent 23b5520 commit fe193db

File tree

5 files changed

+146
-3
lines changed

5 files changed

+146
-3
lines changed

README.md

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -243,6 +243,29 @@ def request_contains_software_statement(registration_request):
243243
provider.registration_request_validators.append(request_contains_software_statement)
244244
```
245245

246+
# User logout
247+
248+
RP-initiated logout, as described in [Section 5 of OpenID Connect Session Management](http://openid.net/specs/openid-connect-session-1_0.html#RPLogout)
249+
is supported. The parsed request should be passed to `Provider.logout_user` together with any known subject identifier
250+
for the user, and then `Provider.do_post_logout_redirect` should be called do obey any valid `post_logout_redirect_uri`
251+
included in the request.
252+
253+
```python
254+
def end_session_endpoint(request):
255+
end_session_request = EndSessionRequest().deserialize(request.get_data().decode('utf-8'))
256+
257+
try:
258+
provider.logout_user(session.get('sub'), end_session_request)
259+
except InvalidSubjectIdentifier as e:
260+
return HTTPResponse('Logout unsuccessful!', content-type='text/html', status=400)
261+
262+
redirect_url = provider.do_post_logout_redirect(end_session_request)
263+
if redirect_url:
264+
return HTTPResponse(redirect_url, status=303)
265+
266+
return HTTPResponse('Logout successful!', content-type='text/html')
267+
```
268+
246269
# Exceptions
247270
All exceptions, except `AuthorizationError`, inherits from `ValueError`. However it might be necessary to distinguish
248271
between them to send the correct error message back to the client according to the OpenID Connect specifications.

src/pyop/authz_state.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -335,3 +335,13 @@ def get_subject_identifier_for_code(self, authorization_code):
335335
raise InvalidAuthorizationCode('{} unknown'.format(authorization_code))
336336

337337
return self.authorization_codes[authorization_code]['sub']
338+
339+
def delete_state_for_subject_identifier(self, subject_identifier):
340+
# type (str) -> None
341+
if not self._is_valid_subject_identifier(subject_identifier):
342+
raise InvalidSubjectIdentifier('Trying to delete state for unknown subject identifier')
343+
344+
for tokens in [self.authorization_codes, self.access_tokens]:
345+
tokens_to_remove = [k for k, v in tokens.items() if v['sub'] == subject_identifier]
346+
for ac in tokens_to_remove:
347+
tokens.pop(ac, None)

src/pyop/provider.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@
1515
from oic.oic.message import AccessTokenResponse
1616
from oic.oic.message import AuthorizationRequest
1717
from oic.oic.message import AuthorizationResponse
18+
from oic.oic.message import EndSessionRequest
19+
from oic.oic.message import EndSessionResponse
1820
from oic.oic.message import IdToken
1921
from oic.oic.message import OpenIDSchema
2022
from oic.oic.message import ProviderConfigurationResponse
@@ -486,3 +488,36 @@ def handle_client_registration_request(self, request, http_headers=None):
486488
registration_resp = RegistrationResponse(**response_params)
487489
logger.debug('registration_resp=%s from registration_req', registration_resp, registration_req)
488490
return registration_resp
491+
492+
def logout_user(self, subject_identifier=None, end_session_request=None):
493+
# type: (Optional[str], Optional[oic.oic.message.EndSessionRequest]) -> None
494+
if not end_session_request:
495+
end_session_request = EndSessionRequest()
496+
if 'id_token_hint' in end_session_request:
497+
id_token = IdToken().from_jwt(end_session_request['id_token_hint'], key=[self.signing_key])
498+
subject_identifier = id_token['sub']
499+
500+
self.authz_state.delete_state_for_subject_identifier(subject_identifier)
501+
502+
def do_post_logout_redirect(self, end_session_request):
503+
# type: (oic.oic.message.EndSessionRequest) -> oic.oic.message.EndSessionResponse
504+
if 'post_logout_redirect_uri' not in end_session_request:
505+
return None
506+
507+
client_id = None
508+
if 'id_token_hint' in end_session_request:
509+
id_token = IdToken().from_jwt(end_session_request['id_token_hint'], key=[self.signing_key])
510+
client_id = id_token['aud'][0]
511+
512+
if 'post_logout_redirect_uri' in end_session_request:
513+
if not client_id:
514+
return None
515+
if not end_session_request['post_logout_redirect_uri'] in self.clients[client_id].get(
516+
'post_logout_redirect_uris', []):
517+
return None
518+
519+
end_session_response = EndSessionResponse()
520+
if 'state' in end_session_request:
521+
end_session_response['state'] = end_session_request['state']
522+
523+
return end_session_response.request(end_session_request['post_logout_redirect_uri'])

tests/pyop/test_authz_state.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -413,3 +413,21 @@ def test_get_subject_identifier_for_code(self, authorization_state, authorizatio
413413
authz_code = authorization_state.create_authorization_code(authorization_request, self.TEST_SUBJECT_IDENTIFIER)
414414
sub = authorization_state.get_subject_identifier_for_code(authz_code)
415415
assert sub == self.TEST_SUBJECT_IDENTIFIER
416+
417+
def test_remove_state_for_subject_identifier(self, authorization_state, authorization_request):
418+
self.set_valid_subject_identifier(authorization_state)
419+
authz_code1 = authorization_state.create_authorization_code(authorization_request, self.TEST_SUBJECT_IDENTIFIER)
420+
authz_code2 = authorization_state.create_authorization_code(authorization_request, self.TEST_SUBJECT_IDENTIFIER)
421+
access_token1 = authorization_state.create_access_token(authorization_request, self.TEST_SUBJECT_IDENTIFIER)
422+
access_token2 = authorization_state.create_access_token(authorization_request, self.TEST_SUBJECT_IDENTIFIER)
423+
424+
authorization_state.delete_state_for_subject_identifier(self.TEST_SUBJECT_IDENTIFIER)
425+
426+
for ac in [authz_code1, authz_code2]:
427+
assert ac not in authorization_state.authorization_codes
428+
for at in [access_token1, access_token2]:
429+
assert at.value not in authorization_state.access_tokens
430+
431+
def test_remove_state_for_unknown_subject_identifier(self, authorization_state):
432+
with pytest.raises(InvalidSubjectIdentifier):
433+
authorization_state.delete_state_for_subject_identifier('unknown')

tests/pyop/test_provider.py

Lines changed: 60 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,13 +12,13 @@
1212
from oic import rndstr
1313
from oic.oauth2.message import MissingRequiredValue, MissingRequiredAttribute
1414
from oic.oic import PREFERENCE2PROVIDER
15-
from oic.oic.message import IdToken, AuthorizationRequest, ClaimsRequest, Claims
15+
from oic.oic.message import IdToken, AuthorizationRequest, ClaimsRequest, Claims, EndSessionRequest, EndSessionResponse
1616

1717
from pyop.access_token import BearerTokenError
1818
from pyop.authz_state import AuthorizationState
1919
from pyop.client_authentication import InvalidClientAuthentication
2020
from pyop.exceptions import InvalidAuthenticationRequest, AuthorizationError, InvalidTokenRequest, \
21-
InvalidClientRegistrationRequest, InvalidAccessToken
21+
InvalidClientRegistrationRequest, InvalidAccessToken, InvalidAuthorizationCode, InvalidSubjectIdentifier
2222
from pyop.provider import Provider, redirect_uri_is_in_registered_redirect_uris, \
2323
response_type_is_in_registered_response_types
2424
from pyop.subject_identifier import HashBasedSubjectIdentifierFactory
@@ -69,7 +69,9 @@ def inject_provider(request):
6969
'redirect_uris': [TEST_REDIRECT_URI],
7070
'response_types': ['code'],
7171
'client_secret': TEST_CLIENT_SECRET,
72-
'token_endpoint_auth_method': 'client_secret_post'
72+
'token_endpoint_auth_method': 'client_secret_post',
73+
'post_logout_redirect_uris': ['https://client.example.com/post_logout']
74+
7375
}
7476
}
7577

@@ -558,3 +560,58 @@ class TestProviderJWKS(object):
558560
def test_jwks(self):
559561
provider = Provider(rsa_key(), {'issuer': ISSUER}, None, None, None)
560562
assert provider.jwks == {'keys': [provider.signing_key.serialize()]}
563+
564+
565+
@pytest.mark.usefixtures('inject_provider')
566+
class TestRPInitiatedLogout(object):
567+
def test_logout_user_with_subject_identifier(self):
568+
auth_req = AuthorizationRequest(response_type='code id_token token', scope='openid', client_id='client1',
569+
redirect_uri='https://client.example.com/redirect')
570+
auth_resp = self.provider.authorize(auth_req, 'user1')
571+
572+
id_token = IdToken().from_jwt(auth_resp['id_token'], key=[self.provider.signing_key])
573+
self.provider.logout_user(subject_identifier=id_token['sub'])
574+
with pytest.raises(InvalidAccessToken):
575+
self.provider.authz_state.introspect_access_token(auth_resp['access_token'])
576+
with pytest.raises(InvalidAuthorizationCode):
577+
self.provider.authz_state.exchange_code_for_token(auth_resp['code'])
578+
579+
def test_logout_user_with_id_token_hint(self):
580+
auth_req = AuthorizationRequest(response_type='code id_token token', scope='openid', client_id='client1',
581+
redirect_uri='https://client.example.com/redirect')
582+
auth_resp = self.provider.authorize(auth_req, 'user1')
583+
584+
self.provider.logout_user(end_session_request=EndSessionRequest(id_token_hint=auth_resp['id_token']))
585+
with pytest.raises(InvalidAccessToken):
586+
self.provider.authz_state.introspect_access_token(auth_resp['access_token'])
587+
with pytest.raises(InvalidAuthorizationCode):
588+
self.provider.authz_state.exchange_code_for_token(auth_resp['code'])
589+
590+
def test_logout_user_with_unknown_subject_identifier(self):
591+
with pytest.raises(InvalidSubjectIdentifier):
592+
self.provider.logout_user(subject_identifier='unknown')
593+
594+
def test_post_logout_redirect(self):
595+
auth_req = AuthorizationRequest(response_type='code id_token token', scope='openid', client_id='client1',
596+
redirect_uri='https://client.example.com/redirect')
597+
auth_resp = self.provider.authorize(auth_req, 'user1')
598+
end_session_request = EndSessionRequest(id_token_hint=auth_resp['id_token'],
599+
post_logout_redirect_uri='https://client.example.com/post_logout',
600+
state='state')
601+
redirect_url = self.provider.do_post_logout_redirect(end_session_request)
602+
assert redirect_url == EndSessionResponse(state='state').request('https://client.example.com/post_logout')
603+
604+
def test_post_logout_redirect_without_post_logout_redirect_uri(self):
605+
assert self.provider.do_post_logout_redirect(EndSessionRequest()) is None
606+
607+
def test_post_logout_redirect_with_unknown_client_for_post_logout_redirect_uri(self):
608+
end_session_request = EndSessionRequest(post_logout_redirect_uri='https://client.example.com/post_logout')
609+
assert self.provider.do_post_logout_redirect(end_session_request) is None
610+
611+
def test_post_logout_redirect_with_unknown_post_logout_redirect_uri(self):
612+
auth_req = AuthorizationRequest(response_type='code id_token token', scope='openid', client_id='client1',
613+
redirect_uri='https://client.example.com/redirect')
614+
auth_resp = self.provider.authorize(auth_req, 'user1')
615+
end_session_request = EndSessionRequest(id_token_hint=auth_resp['id_token'],
616+
post_logout_redirect_uri='https://client.example.com/unknown')
617+
assert self.provider.do_post_logout_redirect(end_session_request) is None

0 commit comments

Comments
 (0)