1 """Component to allow users to login and get tokens.
5 This is an OAuth2 endpoint for granting tokens. We currently support the grant
6 types "authorization_code" and "refresh_token". Because we follow the OAuth2
7 spec, data should be send in formatted as x-www-form-urlencoded. Examples will
8 be in JSON as it's more readable.
10 ## Grant type authorization_code
12 Exchange the authorization code retrieved from the login flow for tokens.
15 "client_id": "https://hassbian.local:8123/",
16 "grant_type": "authorization_code",
17 "code": "411ee2f916e648d691e937ae9344681e"
20 Return value will be the access and refresh tokens. The access token will have
21 a limited expiration. New access tokens can be requested using the refresh
22 token. The value ha_auth_provider will contain the auth provider type that was
23 used to authorize the refresh token.
26 "access_token": "ABCDEFGH",
28 "refresh_token": "IJKLMNOPQRST",
29 "token_type": "Bearer",
30 "ha_auth_provider": "homeassistant"
33 ## Grant type refresh_token
35 Request a new access token using a refresh token.
38 "client_id": "https://hassbian.local:8123/",
39 "grant_type": "refresh_token",
40 "refresh_token": "IJKLMNOPQRST"
43 Return value will be a new access token. The access token will have
47 "access_token": "ABCDEFGH",
49 "token_type": "Bearer"
52 ## Revoking a refresh token
54 It is also possible to revoke a refresh token and all access tokens that have
55 ever been granted by that refresh token. Response code will ALWAYS be 200.
58 "token": "IJKLMNOPQRST",
66 Send websocket command `auth/current_user` will return current user of the
67 active websocket connection.
71 "type": "auth/current_user",
74 The result payload likes
85 "auth_provider_type": "homeassistant",
86 "auth_provider_id": null
96 ## Create a long-lived access token
98 Send websocket command `auth/long_lived_access_token` will create
99 a long-lived access token for current user. Access token will not be saved in
100 Home Assistant. User need to record the token in secure place.
104 "type": "auth/long_lived_access_token",
105 "client_name": "GPS Logger",
109 Result will be a long-lived access token:
119 # POST /auth/external/callback
121 This is an endpoint for OAuth2 Authorization callbacks used by integrations
122 that link accounts with other cloud providers using LocalOAuth2Implementation
123 as part of a config flow.
126 from __future__
import annotations
129 from collections.abc
import Callable
130 from datetime
import datetime, timedelta
131 from http
import HTTPStatus
132 from logging
import getLogger
133 from typing
import Any, cast
136 from aiohttp
import web
137 from multidict
import MultiDictProxy
138 import voluptuous
as vol
142 TOKEN_TYPE_LONG_LIVED_ACCESS_TOKEN,
151 async_user_not_allowed_do_auth,
164 from .
import indieauth, login_flow, mfa_setup_flow
168 type StoreResultType = Callable[[str, Credentials], str]
169 type RetrieveResultType = Callable[[str, str], Credentials |
None]
170 DATA_STORE: HassKey[StoreResultType] =
HassKey(DOMAIN)
171 CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN)
173 DELETE_CURRENT_TOKEN_DELAY = 2
178 hass: HomeAssistant, client_id: str, credential: Credentials
180 """Create an authorization code to fetch tokens."""
181 return hass.data[DATA_STORE](client_id, credential)
184 async
def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
185 """Component to allow users to login."""
188 hass.data[DATA_STORE] = store_result
190 hass.http.register_view(
TokenView(retrieve_result))
195 websocket_api.async_register_command(hass, websocket_current_user)
196 websocket_api.async_register_command(hass, websocket_create_long_lived_access_token)
197 websocket_api.async_register_command(hass, websocket_refresh_tokens)
198 websocket_api.async_register_command(hass, websocket_delete_refresh_token)
199 websocket_api.async_register_command(hass, websocket_delete_all_refresh_tokens)
200 websocket_api.async_register_command(hass, websocket_sign_path)
201 websocket_api.async_register_command(hass, websocket_refresh_token_set_expiry)
203 login_flow.async_setup(hass, store_result)
204 mfa_setup_flow.async_setup(hass)
210 """View to revoke tokens."""
213 name =
"api:auth:revocation"
214 requires_auth =
False
217 async
def post(self, request: web.Request) -> web.Response:
218 """Revoke a token."""
219 hass = request.app[KEY_HASS]
220 data = cast(MultiDictProxy[str], await request.post())
226 if (token := data.get(
"token"))
is None:
227 return web.Response(status=HTTPStatus.OK)
229 refresh_token = hass.auth.async_get_refresh_token_by_token(token)
231 if refresh_token
is None:
232 return web.Response(status=HTTPStatus.OK)
234 hass.auth.async_remove_refresh_token(refresh_token)
235 return web.Response(status=HTTPStatus.OK)
239 """View to issue tokens."""
242 name =
"api:auth:token"
243 requires_auth =
False
246 def __init__(self, retrieve_auth: RetrieveResultType) ->
None:
247 """Initialize the token view."""
251 async
def post(self, request: web.Request) -> web.Response:
253 hass = request.app[KEY_HASS]
254 data = cast(MultiDictProxy[str], await request.post())
256 grant_type = data.get(
"grant_type")
262 if data.get(
"action") ==
"revoke":
265 return await RevokeTokenView.post(self, request)
267 if grant_type ==
"authorization_code":
270 if grant_type ==
"refresh_token":
274 {
"error":
"unsupported_grant_type"}, status_code=HTTPStatus.BAD_REQUEST
280 data: MultiDictProxy[str],
281 request: web.Request,
283 """Handle authorization code request."""
284 client_id = data.get(
"client_id")
285 if client_id
is None or not indieauth.verify_client_id(client_id):
287 {
"error":
"invalid_request",
"error_description":
"Invalid client id"},
288 status_code=HTTPStatus.BAD_REQUEST,
291 if (code := data.get(
"code"))
is None:
293 {
"error":
"invalid_request",
"error_description":
"Invalid code"},
294 status_code=HTTPStatus.BAD_REQUEST,
299 if credential
is None or not isinstance(credential, Credentials):
301 {
"error":
"invalid_request",
"error_description":
"Invalid code"},
302 status_code=HTTPStatus.BAD_REQUEST,
305 user = await hass.auth.async_get_or_create_user(credential)
310 "error":
"access_denied",
311 "error_description": user_access_error,
313 status_code=HTTPStatus.FORBIDDEN,
316 refresh_token = await hass.auth.async_create_refresh_token(
317 user, client_id, credential=credential
320 access_token = hass.auth.async_create_access_token(
321 refresh_token, request.remote
323 except InvalidAuthError
as exc:
325 {
"error":
"access_denied",
"error_description":
str(exc)},
326 status_code=HTTPStatus.FORBIDDEN,
331 "access_token": access_token,
332 "token_type":
"Bearer",
333 "refresh_token": refresh_token.token,
335 refresh_token.access_token_expiration.total_seconds()
337 "ha_auth_provider": credential.auth_provider_type,
340 "Cache-Control":
"no-store",
341 "Pragma":
"no-cache",
348 data: MultiDictProxy[str],
349 request: web.Request,
351 """Handle refresh token request."""
352 client_id = data.get(
"client_id")
353 if client_id
is not None and not indieauth.verify_client_id(client_id):
355 {
"error":
"invalid_request",
"error_description":
"Invalid client id"},
356 status_code=HTTPStatus.BAD_REQUEST,
359 if (token := data.get(
"refresh_token"))
is None:
361 {
"error":
"invalid_request"}, status_code=HTTPStatus.BAD_REQUEST
364 refresh_token = hass.auth.async_get_refresh_token_by_token(token)
366 if refresh_token
is None:
368 {
"error":
"invalid_grant"}, status_code=HTTPStatus.BAD_REQUEST
371 if refresh_token.client_id != client_id:
373 {
"error":
"invalid_request"}, status_code=HTTPStatus.BAD_REQUEST
377 hass, refresh_token.user
381 "error":
"access_denied",
382 "error_description": user_access_error,
384 status_code=HTTPStatus.FORBIDDEN,
388 access_token = hass.auth.async_create_access_token(
389 refresh_token, request.remote
391 except InvalidAuthError
as exc:
393 {
"error":
"access_denied",
"error_description":
str(exc)},
394 status_code=HTTPStatus.FORBIDDEN,
399 "access_token": access_token,
400 "token_type":
"Bearer",
402 refresh_token.access_token_expiration.total_seconds()
406 "Cache-Control":
"no-store",
407 "Pragma":
"no-cache",
413 """View to link existing users to new credentials."""
415 url =
"/auth/link_user"
416 name =
"api:auth:link_user"
418 def __init__(self, retrieve_credentials: RetrieveResultType) ->
None:
419 """Initialize the link user view."""
422 @RequestDataValidator(vol.Schema({"code": str, "client_id": str}))
423 async
def post(self, request: web.Request, data: dict[str, Any]) -> web.Response:
425 hass = request.app[KEY_HASS]
426 user: User = request[
"hass_user"]
430 if credentials
is None:
431 return self.json_message(
"Invalid code", status_code=HTTPStatus.BAD_REQUEST)
433 linked_user = await hass.auth.async_get_user_by_credentials(credentials)
434 if linked_user != user
and linked_user
is not None:
435 return self.json_message(
436 "Credential already linked", status_code=HTTPStatus.BAD_REQUEST
440 if linked_user != user:
441 await hass.auth.async_link_user(user, credentials)
442 return self.json_message(
"User linked")
447 """Create an in memory store."""
448 temp_results: dict[tuple[str, str], tuple[datetime, Credentials]] = {}
451 def store_result(client_id: str, result: Credentials) -> str:
452 """Store flow result and return a code to retrieve it."""
453 if not isinstance(result, Credentials):
454 raise TypeError(
"result has to be a Credentials instance")
456 code = uuid.uuid4().hex
457 temp_results[(client_id, code)] = (
464 def retrieve_result(client_id: str, code: str) -> Credentials |
None:
465 """Retrieve flow result."""
466 key = (client_id, code)
468 if key
not in temp_results:
471 created, result = temp_results.pop(key)
477 if dt_util.utcnow() - created <
timedelta(minutes=10):
482 return store_result, retrieve_result
485 @websocket_api.websocket_command({vol.Required("type"):
"auth/current_user"})
486 @websocket_api.ws_require_user()
487 @websocket_api.async_response
491 """Return the current user."""
492 user = connection.user
493 enabled_modules = await hass.auth.async_get_enabled_mfa(user)
495 connection.send_message(
496 websocket_api.result_message(
501 "is_owner": user.is_owner,
502 "is_admin": user.is_admin,
505 "auth_provider_type": c.auth_provider_type,
506 "auth_provider_id": c.auth_provider_id,
508 for c
in user.credentials
514 "enabled": module.id
in enabled_modules,
516 for module
in hass.auth.auth_mfa_modules
523 @websocket_api.websocket_command(
{
vol.Required("type"):
"auth/long_lived_access_token",
524 vol.Required(
"lifespan"): int,
525 vol.Required(
"client_name"): str,
526 vol.Optional(
"client_icon"): str,
529 @websocket_api.ws_require_user()
530 @websocket_api.async_response
534 """Create or a long-lived access token."""
535 refresh_token = await hass.auth.async_create_refresh_token(
537 client_name=msg[
"client_name"],
538 client_icon=msg.get(
"client_icon"),
539 token_type=TOKEN_TYPE_LONG_LIVED_ACCESS_TOKEN,
540 access_token_expiration=
timedelta(days=msg[
"lifespan"]),
544 access_token = hass.auth.async_create_access_token(refresh_token)
545 except InvalidAuthError
as exc:
546 connection.send_error(msg[
"id"], websocket_api.ERR_UNAUTHORIZED,
str(exc))
549 connection.send_result(msg[
"id"], access_token)
552 @websocket_api.websocket_command({vol.Required("type"):
"auth/refresh_tokens"})
553 @websocket_api.ws_require_user()
558 """Return metadata of users refresh tokens."""
559 current_id = connection.refresh_token_id
561 tokens: list[dict[str, Any]] = []
562 for refresh
in connection.user.refresh_tokens.values():
563 if refresh.credential:
564 auth_provider_type = refresh.credential.auth_provider_type
566 auth_provider_type =
None
569 if refresh.expire_at:
570 expire_at = dt_util.utc_from_timestamp(refresh.expire_at)
574 "auth_provider_type": auth_provider_type,
575 "client_icon": refresh.client_icon,
576 "client_id": refresh.client_id,
577 "client_name": refresh.client_name,
578 "created_at": refresh.created_at,
579 "expire_at": expire_at,
581 "is_current": refresh.id == current_id,
582 "last_used_at": refresh.last_used_at,
583 "last_used_ip": refresh.last_used_ip,
584 "type": refresh.token_type,
588 connection.send_result(msg[
"id"], tokens)
592 @websocket_api.websocket_command(
{
vol.Required("type"):
"auth/delete_refresh_token",
593 vol.Required(
"refresh_token_id"): str,
596 @websocket_api.ws_require_user()
600 """Handle a delete refresh token request."""
601 refresh_token = connection.user.refresh_tokens.get(msg[
"refresh_token_id"])
603 if refresh_token
is None:
604 connection.send_error(msg[
"id"],
"invalid_token_id",
"Received invalid token")
607 hass.auth.async_remove_refresh_token(refresh_token)
609 connection.send_result(msg[
"id"], {})
613 @websocket_api.websocket_command(
{
vol.Required("type"):
"auth/delete_all_refresh_tokens",
614 vol.Optional(
"token_type"): cv.string,
615 vol.Optional(
"delete_current_token", default=
True): bool,
618 @websocket_api.ws_require_user()
622 """Handle delete all refresh tokens request."""
623 current_refresh_token: RefreshToken
624 remove_failed =
False
625 token_type = msg.get(
"token_type")
626 delete_current_token = msg.get(
"delete_current_token")
627 limit_token_types = token_type
is not None
629 for token
in list(connection.user.refresh_tokens.values()):
630 if token.id == connection.refresh_token_id:
634 current_refresh_token = token
636 if limit_token_types
and token_type != token.token_type:
639 hass.auth.async_remove_refresh_token(token)
641 getLogger(__name__).exception(
"Error during refresh token removal")
645 connection.send_error(
646 msg[
"id"],
"token_removing_error",
"During removal, an error was raised."
649 connection.send_result(msg[
"id"], {})
651 async
def _delete_current_token_soon() -> None:
652 """Delete the current token after a delay.
654 We do not want to delete the current token immediately as it will
655 close the connection.
657 This is implemented as a tracked task to ensure the token
658 is still deleted if Home Assistant is shut down during
661 It should not be refactored to use a call_later as that
662 would not be tracked and the token would not be deleted
663 if Home Assistant was shut down during the delay.
666 await asyncio.sleep(DELETE_CURRENT_TOKEN_DELAY)
670 hass.auth.async_remove_refresh_token(current_refresh_token)
672 if delete_current_token
and (
673 not limit_token_types
or current_refresh_token.token_type == token_type
678 hass.async_create_task(_delete_current_token_soon())
681 @websocket_api.websocket_command(
{
vol.Required("type"):
"auth/sign_path",
682 vol.Required(
"path"): str,
683 vol.Optional(
"expires", default=30): int,
686 @websocket_api.ws_require_user()
691 """Handle a sign path request."""
692 connection.send_message(
693 websocket_api.result_message(
707 @websocket_api.websocket_command(
{
vol.Required("type"):
"auth/refresh_token_set_expiry",
708 vol.Required(
"refresh_token_id"): str,
709 vol.Required(
"enable_expiry"): bool,
712 @websocket_api.ws_require_user()
716 """Handle a set expiry of a refresh token request."""
717 refresh_token = connection.user.refresh_tokens.get(msg[
"refresh_token_id"])
719 if refresh_token
is None:
720 connection.send_error(msg[
"id"],
"invalid_token_id",
"Received invalid token")
723 hass.auth.async_set_expiry(refresh_token, enable_expiry=msg[
"enable_expiry"])
724 connection.send_result(msg[
"id"], {})
725
None __init__(self, RetrieveResultType retrieve_credentials)
web.Response post(self, web.Request request, dict[str, Any] data)
web.Response post(self, web.Request request)
web.Response _async_handle_refresh_token(self, HomeAssistant hass, MultiDictProxy[str] data, web.Request request)
web.Response _async_handle_auth_code(self, HomeAssistant hass, MultiDictProxy[str] data, web.Request request)
None __init__(self, RetrieveResultType retrieve_auth)
web.Response post(self, web.Request request)
None websocket_current_user(HomeAssistant hass, websocket_api.ActiveConnection connection, dict[str, Any] msg)
str create_auth_code(HomeAssistant hass, str client_id, Credentials credential)
tuple[StoreResultType, RetrieveResultType] _create_auth_code_store()
None websocket_delete_refresh_token(HomeAssistant hass, websocket_api.ActiveConnection connection, dict[str, Any] msg)
None websocket_refresh_tokens(HomeAssistant hass, websocket_api.ActiveConnection connection, dict[str, Any] msg)
bool async_setup(HomeAssistant hass, ConfigType config)
None websocket_sign_path(HomeAssistant hass, websocket_api.ActiveConnection connection, dict[str, Any] msg)
None websocket_create_long_lived_access_token(HomeAssistant hass, websocket_api.ActiveConnection connection, dict[str, Any] msg)
None websocket_delete_all_refresh_tokens(HomeAssistant hass, websocket_api.ActiveConnection connection, dict[str, Any] msg)
None websocket_refresh_token_set_expiry(HomeAssistant hass, websocket_api.ActiveConnection connection, dict[str, Any] msg)
str|None async_user_not_allowed_do_auth(HomeAssistant hass, User user, Request|None request=None)
str async_sign_path(HomeAssistant hass, str path, timedelta expiration, *str|None refresh_token_id=None, bool use_content_user=False)