Skip to content

Commit af43d1f

Browse files
authored
Merge pull request #8 from its-dirg/user-logout
Add support for user logout.
2 parents 23b5520 + fe193db commit af43d1f

File tree

5 files changed

+146
-3
lines changed

5 files changed

+146
-3
lines changed

Diff for: README.md

+23
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.

Diff for: src/pyop/authz_state.py

+10
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)

Diff for: src/pyop/provider.py

+35
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'])

Diff for: tests/pyop/test_authz_state.py

+18
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')

Diff for: tests/pyop/test_provider.py

+60-3
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)