├── .gitignore ├── hacs.json ├── images └── screenshot1.png ├── custom_components └── virtual_keys │ ├── const.py │ ├── manifest.json │ ├── dist │ ├── login.html │ └── virtual-keys.js │ ├── config_flow.py │ └── __init__.py ├── .editorconfig ├── .github └── workflows │ ├── hassfest.yml │ └── validate.yaml └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store -------------------------------------------------------------------------------- /hacs.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Virtual Keys", 3 | "render_readme": true 4 | } -------------------------------------------------------------------------------- /images/screenshot1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kcsoft/virtual-keys/HEAD/images/screenshot1.png -------------------------------------------------------------------------------- /custom_components/virtual_keys/const.py: -------------------------------------------------------------------------------- 1 | DOMAIN = "virtual_keys" 2 | TITLE = DOMAIN.replace("_", " ").title() 3 | NAME = DOMAIN.replace("_", "-") 4 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = space 5 | indent_size = 2 6 | end_of_line = lf 7 | insert_final_newline = true 8 | trim_trailing_whitespace = true 9 | 10 | [*.py] 11 | indent_size = 4 12 | -------------------------------------------------------------------------------- /.github/workflows/hassfest.yml: -------------------------------------------------------------------------------- 1 | name: Validate with hassfest 2 | 3 | on: 4 | push: 5 | pull_request: 6 | schedule: 7 | - cron: '0 0 * * *' 8 | 9 | jobs: 10 | validate: 11 | runs-on: "ubuntu-latest" 12 | steps: 13 | - uses: "actions/checkout@v4" 14 | - uses: "home-assistant/actions/hassfest@master" 15 | -------------------------------------------------------------------------------- /.github/workflows/validate.yaml: -------------------------------------------------------------------------------- 1 | name: Validate 2 | 3 | on: 4 | push: 5 | pull_request: 6 | schedule: 7 | - cron: "0 0 * * *" 8 | workflow_dispatch: 9 | 10 | jobs: 11 | validate-hacs: 12 | runs-on: "ubuntu-latest" 13 | steps: 14 | - name: HACS validation 15 | uses: "hacs/action@main" 16 | with: 17 | category: "integration" 18 | -------------------------------------------------------------------------------- /custom_components/virtual_keys/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "domain": "virtual_keys", 3 | "name": "Virtual Keys", 4 | "codeowners": ["@kcsoft"], 5 | "config_flow": true, 6 | "documentation": "https://github.com/kcsoft/virtual-keys", 7 | "iot_class": "local_push", 8 | "issue_tracker": "https://github.com/kcsoft/virtual-keys/issues", 9 | "single_config_entry": true, 10 | "version": "1.0.1" 11 | } 12 | -------------------------------------------------------------------------------- /custom_components/virtual_keys/dist/login.html: -------------------------------------------------------------------------------- 1 | 2 | 3 |
4 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /custom_components/virtual_keys/config_flow.py: -------------------------------------------------------------------------------- 1 | from homeassistant import config_entries 2 | from .const import DOMAIN, TITLE 3 | 4 | TITLE = DOMAIN.replace("_", " ").title() 5 | 6 | class VirtualKeysConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): 7 | VERSION = 1 8 | 9 | async def async_step_user(self, user_input=None): 10 | if user_input is not None: 11 | return self.async_create_entry(title=TITLE, data={}) 12 | return self.async_show_form(step_id="user") 13 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Virtual Keys 2 | [](https://github.com/hacs/integration) 3 | 4 | Create login link for [Home Assistant](https://www.home-assistant.io/) that you can share with guests. 5 | 6 |  7 | 8 | ## Description 9 | 10 | Virtual Keys is a Home Assistant integration that allows you to create login links that can be shared with guests. These links provide access to specific entities in Home Assistant for a limited time. 11 | 12 | See [Lovelace Virtual Keys](https://github.com/kcsoft/lovelace-virtual-keys) for a more detailed description. 13 | 14 | ## Installation with HACS 15 | 16 | You need to install [HACS](https://hacs.xyz/) first. 17 | 18 | 1. In HACS, go to Integrations and click on the three dots in the top right corner. Select "Custom repositories". 19 | 20 | 2. Add `kcsoft/virtual-keys` as the repository and select the category `Integration`. 21 | 22 | 3. Search for "Virtual Keys" and download it. 23 | 24 | 4. Restart Home Assistant. 25 | 26 | 5. In Home Assistant, go to Settings -> Devices & Services -> Integrations and add "Virtual Keys". A new entry will appear in the sidebar. 27 | 28 | 29 | ## Use case 30 | 31 | I want to share a "virtual key" with my friends that is valid for a limited time and that they can use to access specific entities in Home Assistant like the front gate. The key is actually a link to my Home Assistant that can be opened in a browser. 32 | 33 | To make this work, I need to make some additional steps (after installing Virtual Keys): 34 | 35 | 1. Create a new user in Home Assistant, e.g., "guest". 36 | 37 | 2. Create a new group, e.g., "guests", and add the user "guest" to it, and also the devices you want to give access to, e.g., "cover.front_gate". Instructions [here](https://developers.home-assistant.io/blog/2019/03/11/user-permissions/). 38 | 39 | 3. Create a new View (tab) in the default Lovelace UI and add the entities you want to give access to, e.g., "cover.front_gate", set the visibility to only show to user "guest". 40 | 41 | 4. Install [kiosk-mode](https://github.com/NemesisRE/kiosk-mode) and configure it to set "kiosk" mode for user "guest". 42 | 43 | That's it, you can now create Virtual Keys and share the link. 44 | -------------------------------------------------------------------------------- /custom_components/virtual_keys/__init__.py: -------------------------------------------------------------------------------- 1 | from datetime import timedelta 2 | from typing import Any 3 | import voluptuous as vol 4 | import jwt 5 | from homeassistant.core import HomeAssistant 6 | from homeassistant.auth.models import TOKEN_TYPE_LONG_LIVED_ACCESS_TOKEN 7 | from homeassistant.components import frontend, panel_custom, websocket_api 8 | from homeassistant.util import dt as dt_util 9 | import os 10 | import aiofiles 11 | 12 | from .const import DOMAIN, TITLE, NAME 13 | 14 | async def async_setup(hass: HomeAssistant, config) -> bool: 15 | websocket_api.async_register_command(hass, list_users) 16 | websocket_api.async_register_command(hass, create_token) 17 | websocket_api.async_register_command(hass, delete_token) 18 | 19 | source_dir = os.path.join(hass.config.path(), "custom_components", DOMAIN, "dist") 20 | dest_dir = os.path.join(hass.config.path(), "www", "community", NAME) 21 | 22 | if not os.path.exists(dest_dir): 23 | await hass.async_add_executor_job(os.makedirs, dest_dir) 24 | 25 | for filename in await hass.async_add_executor_job(os.listdir, source_dir): 26 | source_file = os.path.join(source_dir, filename) 27 | if os.path.isfile(source_file): 28 | async with aiofiles.open(source_file, "rb") as file: 29 | content = await file.read() 30 | async with aiofiles.open(os.path.join(dest_dir, filename), "wb") as file: 31 | await file.write(content) 32 | 33 | return True 34 | 35 | async def async_setup_entry(hass: HomeAssistant, entry) -> bool: 36 | if DOMAIN in hass.data.get("frontend_panels", {}): 37 | frontend.async_remove_panel(hass, DOMAIN) 38 | 39 | await panel_custom.async_register_panel( 40 | hass, 41 | webcomponent_name=NAME, 42 | frontend_url_path=DOMAIN, 43 | module_url=f"/local/community/{NAME}/{NAME}.js", 44 | sidebar_title=TITLE, 45 | sidebar_icon="mdi:key-variant", 46 | require_admin=True, 47 | config={} 48 | ) 49 | 50 | return True 51 | 52 | async def async_unload_entry(hass: HomeAssistant, config_entry): 53 | if DOMAIN in hass.data.get("frontend_panels", {}): 54 | frontend.async_remove_panel(hass, DOMAIN) 55 | 56 | return True 57 | 58 | @websocket_api.websocket_command({vol.Required("type"): "virtual_keys/list_users"}) 59 | @websocket_api.require_admin 60 | @websocket_api.async_response 61 | async def list_users( 62 | hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any] 63 | ) -> None: 64 | result = [] 65 | now = dt_util.utcnow() 66 | 67 | for user in await hass.auth.async_get_users(): 68 | ha_username = next((cred.data.get("username") for cred in user.credentials if cred.auth_provider_type == "homeassistant"), None) 69 | 70 | tokens = [] 71 | for token in list(user.refresh_tokens.values()): 72 | expiration_seconds = token.access_token_expiration.total_seconds() 73 | if (token.token_type == TOKEN_TYPE_LONG_LIVED_ACCESS_TOKEN 74 | and token.created_at + timedelta(seconds=expiration_seconds) < now): 75 | await hass.auth.async_remove_refresh_token(token) 76 | else: 77 | jwt_token = jwt.encode( 78 | { "iss": token.id, "iat": now, "exp": now + token.access_token_expiration }, 79 | token.jwt_key, 80 | algorithm="HS256", 81 | ) 82 | 83 | tokens.append({ 84 | "id": token.id, 85 | "name": token.client_name, 86 | "jwt_token": jwt_token, 87 | "type": token.token_type, 88 | "expiration": expiration_seconds, 89 | "remaining": round((token.created_at + timedelta(seconds=expiration_seconds) - now).total_seconds()), 90 | "created_at": token.created_at.isoformat() 91 | }) 92 | 93 | result.append({ 94 | "id": user.id, 95 | "username": ha_username, 96 | "name": user.name, 97 | "is_owner": user.is_owner, 98 | "is_active": user.is_active, 99 | "local_only": user.local_only, 100 | "system_generated": user.system_generated, 101 | "group_ids": [group.id for group in user.groups], 102 | "credentials": [{"type": c.auth_provider_type} for c in user.credentials], 103 | "tokens": tokens, 104 | }) 105 | 106 | connection.send_result(msg["id"], result) 107 | 108 | 109 | @websocket_api.websocket_command( 110 | { 111 | vol.Required("type"): "virtual_keys/create_token", 112 | vol.Required("user_id"): str, 113 | vol.Required("name"): str, # token name 114 | vol.Required("minutes"): int, # minutes 115 | } 116 | ) 117 | @websocket_api.require_admin 118 | @websocket_api.async_response 119 | async def create_token( 120 | hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any] 121 | ) -> None: 122 | users = await hass.auth.async_get_users() 123 | 124 | user = next((u for u in users if u.id == msg["user_id"]), None) 125 | if user is None: 126 | connection.send_message( 127 | websocket_api.error_message(msg["id"], websocket_api.const.ERR_NOT_FOUND, "User not found") 128 | ) 129 | return 130 | 131 | try: 132 | refresh_token = await hass.auth.async_create_refresh_token( 133 | user, 134 | client_name=msg.get("name"), 135 | token_type=TOKEN_TYPE_LONG_LIVED_ACCESS_TOKEN, 136 | access_token_expiration=timedelta(minutes=msg["minutes"]), 137 | ) 138 | access_token = hass.auth.async_create_access_token(refresh_token) 139 | except ValueError as err: 140 | connection.send_message( 141 | websocket_api.error_message(msg["id"], websocket_api.const.ERR_UNKNOWN_ERROR, str(err)) 142 | ) 143 | return 144 | 145 | connection.send_result(msg["id"], access_token) 146 | 147 | @websocket_api.websocket_command( 148 | { 149 | vol.Required("type"): "virtual_keys/delete_token", 150 | vol.Required("token_id"): str 151 | } 152 | ) 153 | @websocket_api.require_admin 154 | @websocket_api.async_response 155 | async def delete_token( 156 | hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any] 157 | ) -> None: 158 | for user in await hass.auth.async_get_users(): 159 | for token in list(user.refresh_tokens.values()): 160 | if (token.id == msg.get("token_id")): 161 | hass.auth.async_remove_refresh_token(token) 162 | connection.send_result(msg["id"], True) 163 | return 164 | 165 | connection.send_result(msg["id"], False) 166 | -------------------------------------------------------------------------------- /custom_components/virtual_keys/dist/virtual-keys.js: -------------------------------------------------------------------------------- 1 | import { 2 | LitElement, 3 | html, 4 | css, 5 | } from "https://unpkg.com/lit-element@2.4.0/lit-element.js?module"; 6 | 7 | function humanSeconds(seconds) { 8 | return [ 9 | [Math.floor(seconds / 31536000), 'year'], 10 | [Math.floor((seconds % 31536000) / 86400), 'day'], 11 | [Math.floor(((seconds % 31536000) % 86400) / 3600), 'hour'], 12 | [Math.floor((((seconds % 31536000) % 86400) % 3600) / 60), 'minute'], 13 | [(((seconds % 31536000) % 86400) % 3600) % 60, 'second'], 14 | ].map(([value, label]) => { 15 | return value > 0 ? `${value} ${label}${value !== 1 ? 's' : ''} ` : ''; 16 | }).join(' '); 17 | } 18 | 19 | function humanDate(seconds) { 20 | const now = new Date(); 21 | const date = new Date(now.getTime() + seconds * 1000); 22 | return date.toLocaleString(); 23 | } 24 | 25 | class VirtualKeysPanel extends LitElement { 26 | static get properties() { 27 | return { 28 | hass: { type: Object }, 29 | narrow: { type: Boolean }, 30 | route: { type: Object }, 31 | panel: { type: Object }, 32 | users: { type: Array }, 33 | tokens: { type: Array }, 34 | useExpireMinutes: { type: Boolean }, 35 | }; 36 | } 37 | 38 | constructor() { 39 | super(); 40 | this.users = []; 41 | this.tokens = []; 42 | 43 | // form inputs 44 | this.name = ''; 45 | this.user = ''; 46 | this.useExpireMinutes = true; 47 | this.expireMinutes = 60; 48 | this.expireDate = ''; 49 | this.expireMinutesChanged({ target: { value: this.expireMinutes } }); 50 | } 51 | 52 | fetchUsers() { 53 | this.hass.callWS({ type: 'virtual_keys/list_users' }).then(users => { 54 | this.users = []; 55 | this.tokens = []; 56 | users.filter(user => !user.system_generated && user.is_active).forEach(user => { 57 | this.users.push({ 58 | id: user.id, 59 | name: user.name, 60 | }); 61 | user.tokens.filter(token => token.type === 'long_lived_access_token' && token.expiration !== 315360000) 62 | .forEach(token => { 63 | this.tokens.push({ 64 | id: token.id, 65 | name: token.name, 66 | user: user.name, 67 | jwt_token: token.jwt_token, 68 | expiration: token.expiration, 69 | remaining: token.remaining, 70 | }); 71 | }); 72 | }); 73 | }); 74 | } 75 | 76 | update(changedProperties) { 77 | if (changedProperties.has('hass') && this.hass) { 78 | this.fetchUsers(); 79 | } 80 | super.update(changedProperties); 81 | } 82 | 83 | userChanged(e) { 84 | this.user = e.detail.value; 85 | } 86 | 87 | nameChanged(e) { 88 | this.name = e.target.value; 89 | } 90 | 91 | expireMinutesChanged(e) { 92 | this.expireMinutes = e.target.value; 93 | const date = new Date((new Date().getTime()) + parseInt(this.expireMinutes, 10) * 60000); 94 | this.expireDate = date.toLocaleString('sv'); 95 | } 96 | 97 | expireDateChanged(e) { 98 | const diffInMins = Math.round((new Date(e.detail.value) - new Date()) / 60000); 99 | this.expireMinutes = Math.max(0, diffInMins) + ''; 100 | this.expireDate = e.detail.value; 101 | } 102 | 103 | toggleExpire() { 104 | this.useExpireMinutes = !this.useExpireMinutes; 105 | } 106 | 107 | toggleSideBar() { 108 | this.dispatchEvent(new Event('hass-toggle-menu', { bubbles: true, composed: true})); 109 | } 110 | 111 | validate() { 112 | if (!this.name) { 113 | this.showAlert('Name is required'); 114 | return false; 115 | } 116 | if (!this.user) { 117 | this.showAlert('User is required'); 118 | return false; 119 | } 120 | if (this.useExpireMinutes && !this.expireMinutes) { 121 | this.showAlert('Expire minutes is required'); 122 | return false; 123 | } 124 | if (!this.useExpireMinutes && !this.expireDate) { 125 | this.showAlert('Expire date is required'); 126 | return false; 127 | } 128 | if (parseInt(this.expireMinutes, 10) < 1) { 129 | this.showAlert(this.useExpireMinutes 130 | ? 'Expire minutes must be greater than 0' 131 | : 'Expire date must be in the future'); 132 | return false 133 | } 134 | return true; 135 | } 136 | 137 | addClick() { 138 | if (!this.validate()) { 139 | return; 140 | } 141 | 142 | this.hass.callWS({ 143 | type: 'virtual_keys/create_token', 144 | name: this.name, 145 | user_id: this.user, 146 | minutes: parseInt(this.expireMinutes, 10), 147 | }).then(() => { 148 | this.fetchUsers(); 149 | }).catch(err => { 150 | this.showAlert(err.message); 151 | }); 152 | } 153 | 154 | deleteButton() { 155 | return html``; 158 | } 159 | 160 | showAlert(message) { 161 | const event = new Event('hass-notification', { bubbles: true, composed: true}); 162 | event.detail = { message }; 163 | this.dispatchEvent(event); 164 | } 165 | 166 | deleteClick(e, token) { 167 | e.stopPropagation(); 168 | 169 | this.hass.callWS({ 170 | type: 'virtual_keys/delete_token', 171 | token_id: token.id, 172 | }).then(() => { 173 | this.fetchUsers(); 174 | }).catch(err => { 175 | this.showAlert(err.message); 176 | }); 177 | } 178 | 179 | getLoginUrl(token) { 180 | return this.hass.hassUrl() + 'local/community/virtual-keys/login.html?token=' + token.jwt_token; 181 | } 182 | 183 | listItemClick(e, token) { 184 | navigator.clipboard.writeText(this.getLoginUrl(token)); 185 | this.showAlert('Copied to clipboard ' + token.name); 186 | } 187 | 188 | render() { 189 | return html` 190 |