diff --git a/homeassistant/auth/__init__.py b/homeassistant/auth/__init__.py index 9f5252be67d..51c9c25b474 100644 --- a/homeassistant/auth/__init__.py +++ b/homeassistant/auth/__init__.py @@ -202,6 +202,12 @@ class AuthManager: """Get refresh token by token.""" return await self._store.async_get_refresh_token_by_token(token) + async def async_remove_refresh_token(self, + refresh_token: models.RefreshToken) \ + -> None: + """Delete a refresh token.""" + await self._store.async_remove_refresh_token(refresh_token) + @callback def async_create_access_token(self, refresh_token: models.RefreshToken) -> str: @@ -215,7 +221,7 @@ class AuthManager: async def async_validate_access_token( self, token: str) -> Optional[models.RefreshToken]: - """Return if an access token is valid.""" + """Return refresh token if an access token is valid.""" try: unverif_claims = jwt.decode(token, verify=False) except jwt.InvalidTokenError: diff --git a/homeassistant/auth/auth_store.py b/homeassistant/auth/auth_store.py index 5b26cf2f5f8..0f12d69211c 100644 --- a/homeassistant/auth/auth_store.py +++ b/homeassistant/auth/auth_store.py @@ -136,6 +136,18 @@ class AuthStore: self._async_schedule_save() return refresh_token + async def async_remove_refresh_token( + self, refresh_token: models.RefreshToken) -> None: + """Remove a refresh token.""" + if self._users is None: + await self._async_load() + assert self._users is not None + + for user in self._users.values(): + if user.refresh_tokens.pop(refresh_token.id, None): + self._async_schedule_save() + break + async def async_get_refresh_token( self, token_id: str) -> Optional[models.RefreshToken]: """Get refresh token by id.""" diff --git a/homeassistant/components/auth/__init__.py b/homeassistant/components/auth/__init__.py index 08bb3e679b8..4251b23e514 100644 --- a/homeassistant/components/auth/__init__.py +++ b/homeassistant/components/auth/__init__.py @@ -44,11 +44,23 @@ a limited expiration. "expires_in": 1800, "token_type": "Bearer" } + +## Revoking a refresh token + +It is also possible to revoke a refresh token and all access tokens that have +ever been granted by that refresh token. Response code will ALWAYS be 200. + +{ + "token": "IJKLMNOPQRST", + "action": "revoke" +} + """ import logging import uuid from datetime import timedelta +from aiohttp import web import voluptuous as vol from homeassistant.auth.models import User, Credentials @@ -79,7 +91,7 @@ async def async_setup(hass, config): """Component to allow users to login.""" store_result, retrieve_result = _create_auth_code_store() - hass.http.register_view(GrantTokenView(retrieve_result)) + hass.http.register_view(TokenView(retrieve_result)) hass.http.register_view(LinkUserView(retrieve_result)) hass.components.websocket_api.async_register_command( @@ -92,8 +104,8 @@ async def async_setup(hass, config): return True -class GrantTokenView(HomeAssistantView): - """View to grant tokens.""" +class TokenView(HomeAssistantView): + """View to issue or revoke tokens.""" url = '/auth/token' name = 'api:auth:token' @@ -101,7 +113,7 @@ class GrantTokenView(HomeAssistantView): cors_allowed = True def __init__(self, retrieve_user): - """Initialize the grant token view.""" + """Initialize the token view.""" self._retrieve_user = retrieve_user @log_invalid_auth @@ -112,6 +124,13 @@ class GrantTokenView(HomeAssistantView): grant_type = data.get('grant_type') + # IndieAuth 6.3.5 + # The revocation endpoint is the same as the token endpoint. + # The revocation request includes an additional parameter, + # action=revoke. + if data.get('action') == 'revoke': + return await self._async_handle_revoke_token(hass, data) + if grant_type == 'authorization_code': return await self._async_handle_auth_code(hass, data) @@ -122,6 +141,25 @@ class GrantTokenView(HomeAssistantView): 'error': 'unsupported_grant_type', }, status_code=400) + async def _async_handle_revoke_token(self, hass, data): + """Handle revoke token request.""" + # OAuth 2.0 Token Revocation [RFC7009] + # 2.2 The authorization server responds with HTTP status code 200 + # if the token has been revoked successfully or if the client + # submitted an invalid token. + token = data.get('token') + + if token is None: + return web.Response(status=200) + + refresh_token = await hass.auth.async_get_refresh_token_by_token(token) + + if refresh_token is None: + return web.Response(status=200) + + await hass.auth.async_remove_refresh_token(refresh_token) + return web.Response(status=200) + async def _async_handle_auth_code(self, hass, data): """Handle authorization code request.""" client_id = data.get('client_id') @@ -136,6 +174,7 @@ class GrantTokenView(HomeAssistantView): if code is None: return self.json({ 'error': 'invalid_request', + 'error_description': 'Invalid code', }, status_code=400) user = self._retrieve_user(client_id, RESULT_TYPE_USER, code) diff --git a/tests/auth/test_init.py b/tests/auth/test_init.py index 5ea3b528b4e..5dc6ebf135d 100644 --- a/tests/auth/test_init.py +++ b/tests/auth/test_init.py @@ -281,3 +281,20 @@ async def test_cannot_deactive_owner(mock_hass): with pytest.raises(ValueError): await manager.async_deactivate_user(owner) + + +async def test_remove_refresh_token(mock_hass): + """Test that we can remove a refresh token.""" + manager = await auth.auth_manager_from_config(mock_hass, []) + user = MockUser().add_to_auth_manager(manager) + refresh_token = await manager.async_create_refresh_token(user, CLIENT_ID) + access_token = manager.async_create_access_token(refresh_token) + + await manager.async_remove_refresh_token(refresh_token) + + assert ( + await manager.async_get_refresh_token(refresh_token.id) is None + ) + assert ( + await manager.async_validate_access_token(access_token) is None + ) diff --git a/tests/components/auth/test_init.py b/tests/components/auth/test_init.py index 79749da1461..7b9dda6acb3 100644 --- a/tests/components/auth/test_init.py +++ b/tests/components/auth/test_init.py @@ -224,3 +224,46 @@ async def test_refresh_token_different_client_id(hass, aiohttp_client): await hass.auth.async_validate_access_token(tokens['access_token']) is not None ) + + +async def test_revoking_refresh_token(hass, aiohttp_client): + """Test that we can revoke refresh tokens.""" + client = await async_setup_auth(hass, aiohttp_client) + user = await hass.auth.async_create_user('Test User') + refresh_token = await hass.auth.async_create_refresh_token(user, CLIENT_ID) + + # Test that we can create an access token + resp = await client.post('/auth/token', data={ + 'client_id': CLIENT_ID, + 'grant_type': 'refresh_token', + 'refresh_token': refresh_token.token, + }) + + assert resp.status == 200 + tokens = await resp.json() + assert ( + await hass.auth.async_validate_access_token(tokens['access_token']) + is not None + ) + + # Revoke refresh token + resp = await client.post('/auth/token', data={ + 'token': refresh_token.token, + 'action': 'revoke', + }) + assert resp.status == 200 + + # Old access token should be no longer valid + assert ( + await hass.auth.async_validate_access_token(tokens['access_token']) + is None + ) + + # Test that we no longer can create an access token + resp = await client.post('/auth/token', data={ + 'client_id': CLIENT_ID, + 'grant_type': 'refresh_token', + 'refresh_token': refresh_token.token, + }) + + assert resp.status == 400