Skip to content

Instantly share code, notes, and snippets.

@dkaser
Last active May 5, 2024 17:08
Show Gist options
  • Save dkaser/bcfc82c4f84ef02c81c218f36afdca01 to your computer and use it in GitHub Desktop.
Save dkaser/bcfc82c4f84ef02c81c218f36afdca01 to your computer and use it in GitHub Desktop.
Home Assistant + CloudFlare Zero Trust + Alexa

Home Assistant + CloudFlare Zero Trust + Alexa

Prerequisites

  1. Have CloudFlare Zero Trust configured and working for your HA instance.

System Setup

  1. Create a long-lived token from within Home Assistant (save to a temporary location for later use).
  2. Create a service token within the CloudFlare dashboard for your skill to use (save to a temporary location for later use).
  3. Add a new policy to the CloudFlare application:
    • Action must be set to "Service Auth"
    • Include "Service Token", add the token you created

AWS Configuration

Parameter Store

  1. Within AWS Systems Manager, go to Parameter Store.
  2. Create a new standard parameter:
    • Name: /ha-alexa/appConfig
    • Type: SecureString
    • Value: See SSM_Parameter.json, replacing values:
      • CF_CLIENT_ID: ID for the CloudFlare service token
      • CF_CLIENT_SECRET: Secret for the CloudFlare service token
      • HA_BASE_URL: URL to the CloudFlare application
      • HA_TOKEN: Long-lived token created within Home Assistant
      • WRAPPER_SECRET: Any random/arbitrary value, this will be used to protect calls to the authentication wrapper.
  3. Follow the instructions to create an Alexa smart home skill, with the following adjustments:
    1. When creating the IAM role, add AmazonSSMReadOnlyAccess in addition to AWSLambdaBasicExecutionRole.
    2. When creating the Lambda function:
      • Use the code from Home_Assistant.py
      • Do not set environment variables for BASE_URL or LONG_LIVED_ACCESS_TOKEN
      • Create environment variable: APP_CONFIG_PATH set to /ha-alexa/
    3. After creating the Lambda function from the guide, create a second Lambda function:
      • Use the code from Home_Assistant_Wrapper.py
      • Create environment variable: APP_CONFIG_PATH set to /ha-alexa/
      • Create a function URL, with auth type NONE. Save the function URL path for later use.
    4. When setting up Account Linking in your Alexa skill:
      • Set Access Token URI to the function URL from the wrapper function.
      • Set Your Secret to the value of WRAPPER_SECRET in the configuration parameter.
"""
Copyright 2019 Jason Hu <awaregit at gmail.com>
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
"""
import os, json, logging, urllib3, configparser, boto3, traceback
_debug = bool(os.environ.get('DEBUG'))
_logger = logging.getLogger('HomeAssistant-SmartHome')
_logger.setLevel(logging.DEBUG if _debug else logging.INFO)
# Initialize boto3 client at global scope for connection reuse
client = boto3.client('ssm')
app_config_path = os.environ['APP_CONFIG_PATH']
# Initialize app at global scope for reuse across invocations
app = None
class HA_Config:
def __init__(self, config):
"""
Construct new app with configuration
:param config: application configuration
"""
self.config = config
def get_config(self):
return self.config
def load_config(ssm_parameter_path):
"""
Load configparser from config stored in SSM Parameter Store
:param ssm_parameter_path: Path to app config in SSM Parameter Store
:return: ConfigParser holding loaded config
"""
configuration = configparser.ConfigParser()
try:
# Get all parameters for this app
param_details = client.get_parameters_by_path(
Path=ssm_parameter_path,
Recursive=False,
WithDecryption=True
)
# Loop through the returned parameters and populate the ConfigParser
if 'Parameters' in param_details and len(param_details.get('Parameters')) > 0:
for param in param_details.get('Parameters'):
param_path_array = param.get('Name').split("/")
section_position = len(param_path_array) - 1
section_name = param_path_array[section_position]
config_values = json.loads(param.get('Value'))
config_dict = {section_name: config_values}
configuration.read_dict(config_dict)
except BaseException as err:
print("Encountered an error loading config from SSM.")
print(str(err))
finally:
return configuration
def lambda_handler(event, context):
global app
# Initialize app if it doesn't yet exist
if app is None:
print("Loading config and creating persistence object...")
config = load_config(app_config_path)
app = HA_Config(config)
appConfig = app.get_config()['appConfig']
base_url = appConfig['HA_BASE_URL']
cf_client_id = appConfig['CF_CLIENT_ID']
cf_client_secret = appConfig['CF_CLIENT_SECRET']
directive = event.get('directive')
assert directive is not None, 'Malformatted request - missing directive'
assert directive.get('header', {}).get('payloadVersion') == '3', \
'Only support payloadVersion == 3'
scope = directive.get('endpoint', {}).get('scope')
if scope is None:
# token is in grantee for Linking directive
scope = directive.get('payload', {}).get('grantee')
if scope is None:
# token is in payload for Discovery directive
scope = directive.get('payload', {}).get('scope')
assert scope is not None, 'Malformatted request - missing endpoint.scope'
assert scope.get('type') == 'BearerToken', 'Only support BearerToken'
token = scope.get('token')
if token is None and _debug:
token = appConfig['HA_TOKEN'] # only for debug purpose
verify_ssl = not bool(os.environ.get('NOT_VERIFY_SSL'))
"""Handle incoming Alexa directive."""
_logger.debug('Event: %s', event)
assert base_url is not None, 'Please set BASE_URL environment variable'
base_url = base_url.strip("/")
_logger.debug("Base url: {}".format(base_url))
directive = event.get('directive')
assert directive is not None, 'Malformatted request - missing directive'
assert directive.get('header', {}).get('payloadVersion') == '3', \
'Only support payloadVersion == 3'
http = urllib3.PoolManager(
cert_reqs='CERT_REQUIRED' if verify_ssl else 'CERT_NONE',
timeout=urllib3.Timeout(connect=2.0, read=10.0)
)
api_path = '{}/api/alexa/smart_home'.format(base_url)
response = http.request(
'POST',
api_path,
headers={
'Authorization': 'Bearer {}'.format(token),
'Content-Type': 'application/json',
'CF-Access-Client-Id': cf_client_id,
'CF-Access-Client-Secret': cf_client_secret,
},
body=json.dumps(event).encode('utf-8'),
)
if response.status >= 400:
return {
'event': {
'payload': {
'type': 'INVALID_AUTHORIZATION_CREDENTIAL'
if response.status in (401, 403) else "INTERNAL_ERROR {}".format(response.status),
'message': response.data.decode("utf-8"),
}
}
}
_logger.debug('Response: %s', response.data.decode("utf-8"))
return json.loads(response.data.decode('utf-8'))
"""
Copyright 2019 Jason Hu <awaregit at gmail.com>
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
"""
import os, json, logging, urllib3, configparser, boto3, traceback, base64, urllib
_debug = bool(os.environ.get('DEBUG'))
_logger = logging.getLogger('HomeAssistant-SmartHome')
_logger.setLevel(logging.DEBUG if _debug else logging.INFO)
# Initialize boto3 client at global scope for connection reuse
client = boto3.client('ssm')
app_config_path = os.environ['APP_CONFIG_PATH']
# Initialize app at global scope for reuse across invocations
app = None
class HA_Config:
def __init__(self, config):
"""
Construct new app with configuration
:param config: application configuration
"""
self.config = config
def get_config(self):
return self.config
def load_config(ssm_parameter_path):
"""
Load configparser from config stored in SSM Parameter Store
:param ssm_parameter_path: Path to app config in SSM Parameter Store
:return: ConfigParser holding loaded config
"""
configuration = configparser.ConfigParser()
try:
# Get all parameters for this app
param_details = client.get_parameters_by_path(
Path=ssm_parameter_path,
Recursive=False,
WithDecryption=True
)
# Loop through the returned parameters and populate the ConfigParser
if 'Parameters' in param_details and len(param_details.get('Parameters')) > 0:
for param in param_details.get('Parameters'):
param_path_array = param.get('Name').split("/")
section_position = len(param_path_array) - 1
section_name = param_path_array[section_position]
config_values = json.loads(param.get('Value'))
config_dict = {section_name: config_values}
configuration.read_dict(config_dict)
except BaseException as err:
print("Encountered an error loading config from SSM.")
print(str(err))
finally:
return configuration
def lambda_handler(event, context):
global app
_logger.debug('Event: %s', event)
# Initialize app if it doesn't yet exist
if app is None:
print("Loading config and creating persistence object...")
config = load_config(app_config_path)
app = HA_Config(config)
appConfig = app.get_config()['appConfig']
destination_url = appConfig['HA_BASE_URL']
cf_client_id = appConfig['CF_CLIENT_ID']
cf_client_secret = appConfig['CF_CLIENT_SECRET']
wrapper_secret = appConfig['WRAPPER_SECRET']
assert destination_url is not None, 'Please set BASE_URL parameter'
destination_url = destination_url.strip("/")
http = urllib3.PoolManager(
cert_reqs='CERT_REQUIRED',
timeout=urllib3.Timeout(connect=2.0, read=10.0)
)
req_body = base64.b64decode(event.get('body')) if event.get('isBase64Encoded') else event.get('body')
_logger.debug(req_body)
req_dict = urllib.parse.parse_qs(req_body)
client_secret = req_dict[b'client_secret'][0].decode("utf-8")
assert client_secret == wrapper_secret
headers = {
'Content-Type': 'application/x-www-form-urlencoded',
'CF-Access-Client-Id': cf_client_id,
'CF-Access-Client-Secret': cf_client_secret
}
response = http.request(
'POST',
'{}/auth/token'.format(destination_url),
headers=headers,
body=req_body
)
if response.status >= 400:
_logger.debug("ERROR {} {}".format(response.status, response.data))
return {
'event': {
'payload': {
'type': 'INVALID_AUTHORIZATION_CREDENTIAL'
if response.status in (401, 403) else "INTERNAL_ERROR {}".format(response.status),
'message': response.data.decode("utf-8"),
}
}
}
_logger.debug('Response: %s', response.data.decode("utf-8"))
return json.loads(response.data.decode('utf-8'))
{
"CF_CLIENT_ID": "value.access",
"CF_CLIENT_SECRET": "value",
"HA_BASE_URL": "https://cloudflare.app.path",
"HA_TOKEN": "LongLivedTokenFromHA",
"WRAPPER_SECRET": "ClientSecret"
}
@salvq
Copy link

salvq commented Feb 8, 2023

@dkaser Thanks for such a guide, clear described steps and very helpful. However just observed one issue that is adding devices to Routines in Alexa app.

1.) I am able to add to Alexa and control in Alexa my lights which are exposed from HA
2.) The issue is when I try to add routines, there are no devices i.e. More -> Routines -> + -> Add action -> Smart Home -> there are no devices (not even the ones I see in the Alexa app dashboard)

Do you observe the same issue ? Can you utilize added devices in Alexa and create routines with this devices ?

Thanks

EDIT: I found out that I needed to create at least 1 group in Devices, like kitchen, and then all the devices will show up in the routines. When I remove this group, the devices disappear again (but devices across defined routines are kept)... Very strange behaviour, but may help others, you need at least one group.

@dkaser
Copy link
Author

dkaser commented Feb 8, 2023

I’m glad it was helpful for you! That’s really weird with the groups — I’m happy you were able to figure it out and provide the solution, I already had groups set up when I built the connection so I never encountered that.

@afriberg
Copy link

Hi,
Thx for a good guide.
I am having some problem with CloudFlare, I always get "forbidden" when trying this.
So, I think that the wrapper function is not working for me, did you guys encounter something similar?

@dkaser
Copy link
Author

dkaser commented Mar 1, 2023

Check your policy in Cloudflare and make certain that the action is set to service auth (not the normal permit action). It’s easy to get that wrong during the setup and can result in that kind of error.

@afriberg
Copy link

afriberg commented Mar 1, 2023

Thx for input, I think its correct config due to my test with curl. Will keep looking :)

@dkaser
Copy link
Author

dkaser commented Mar 2, 2023

Where are you getting a forbidden message?

@afriberg
Copy link

afriberg commented Mar 2, 2023

I get it from cloudflare when I trying to do the account link (when you reach to hass login page).

@CortezSMz
Copy link

Thank you for that, it's working great!

Just had some trouble with the standard parameter, after debuggin a bit I realised that I had created the parameters on a diferent AWS Region, thus not loading them correctly.

@nictronik99
Copy link

unfortunately I have to say that I also tried this method, after days of research, I tried a lot of methods but even with this I can't get alexa to work with home assistant without the cloud. i have exactly the setup described here home assistant with zero trust tunnel....please help me. Where should I look for the logs to see what's wrong?

@matteggleton89
Copy link

You're amazing, thanks for this

@GeoSnipes
Copy link

GeoSnipes commented Nov 8, 2023

Still can't get this to bypass Cloudflare bot protect by itself. :(
So far tried WAF custom rules, Client Certs, Configured Rules...... sighz

Had to create a WAF rule allowing: AS14618 and A16509. But it leaves a big block of access to bad actors bypassing Cloudflare protection that use these AS.

@JeredGeist
Copy link

Anyone else getting a "Unable to link the skill at this time. Please try again later." error? I believe I have everything set up correctly, can hit Home Assistant from the skill linking and continue through login, but then I immediately get this error.

@MaxWinterstein
Copy link

MaxWinterstein commented Dec 25, 2023

Can't get this to work, somehow the client_secret seems not to be set.

[ERROR] KeyError: b'client_secret'
Traceback (most recent call last):
  File "/var/task/lambda_function.py", line 108, in lambda_handler
    client_secret = req_dict[b'client_secret'][0].decode("utf-8")

And even if I remove the assert, I get some 404. Adding some debug strings it seems fine, and tries to post against https://example.org/auth/token.

Any advice?

Edit: Oh boy, I messed the two urls up -.- But, now stuck at the same point as @JeredGeist. Linking won't work, http.request returns with 404.

Edit2: Got it 🥳

In my case, and i guess @JeredGeist might have the same issue, the token auth policy was wrongly set. The 'Actionneeds to beService Auth`, not allow.

image

Now i need to remove 412 wrongly added devices from my Alexa 😄


The mentioned forbidden stuff above might come from a wrongly set function URL, when not setting to NONE.

@MaxWinterstein
Copy link

Anyone else getting a "Unable to link the skill at this time. Please try again later." error? I believe I have everything set up correctly, can hit Home Assistant from the skill linking and continue through login, but then I immediately get this error.

@JeredGeist explicit notify as edits won't sendout mail again, pls check my comment above. I edited the solution that might be your issue as well.

@Squallzz
Copy link

Squallzz commented Dec 31, 2023

If I test the wrapper function, I get the following as an error:

Test Event Name
Test

Response
{
  "errorMessage": "b'client_secret'",
  "errorType": "KeyError",
  "stackTrace": [
    "  File \"/var/task/lambda_function.py\", line 101, in lambda_handler\n    client_secret = req_dict[b'client_secret'][0].decode(\"utf-8\")\n"
  ]
}

Function Logs
START RequestId: 7a5838d1-479e-4b1c-bd7b-25a024724214 Version: $LATEST
Loading config and creating persistence object...
[ERROR] KeyError: b'client_secret'
Traceback (most recent call last):
  File "/var/task/lambda_function.py", line 101, in lambda_handler
    client_secret = req_dict[b'client_secret'][0].decode("utf-8")
END RequestId: 7a5838d1-479e-4b1c-bd7b-25a024724214
REPORT RequestId: 7a5838d1-479e-4b1c-bd7b-25a024724214	Duration: 233.97 ms	Billed Duration: 234 ms	Memory Size: 128 MB	Max Memory Used: 74 MB	Init Duration: 440.67 ms

Request ID
7a5838d1-479e-4b1c-bd7b-25a024724214

I also get "Forbidden" when trying to link the Alexa skill.. what am I doing wrong?
@MaxWinterstein You said you got the same issue when the URLs were wrong.. WHICH URLs were wrong for you?

My parameter is named /ha-alexa/appConfig
CF_CLIENT_ID is the "XXXXXX.access" (removed the default "CF-Access-Client-Id" prefix, same situation for the secret.
HA_BASE_URL is my publicly accessible HASS URL https://my.domain.example/

@JeredGeist
Copy link

Fixed my issue, ended up being a typo in HA configuration.yaml file

@Squallzz
Copy link

Squallzz commented Jan 2, 2024

I also got my issue resolved…

@d20z3f
Copy link

d20z3f commented Jan 3, 2024

Still can't get this to bypass Cloudflare bot protect by itself. :( So far tried WAF custom rules, Client Certs, Configured Rules...... sighz

Had to create a WAF rule allowing: AS14618 and A16509. But it leaves a big block of access to bad actors bypassing Cloudflare protection that use these AS.

Is this needed to get it working?

I tried doing it without this WAF rules and I am stuck. I followed this guide to the letter, but I still get "Unable to link the skill at this time. Please try again later."

I have two cloudflare policies set up.

image

The "Default" one for restricting login to only my email address, and the service token auth one named "haaska".

image

Here is my function config

image

When I test my function I get this response (succeeded):

image

I tested with this code

{
"directive": {
"header": {
"namespace": "Alexa.Discovery",
"name": "Discover",
"payloadVersion": "3",
"messageId": "1bd5d003-31b9-476f-ad03-71d471922820"
},
"payload": {
"scope": {
"type": "BearerToken"
}
}
}
}

When I test my wrapper function with the same code I get this error:

image

Config of wrapper function:

image

Wrapper function url

image

HA configuration.yaml:

    # Loads default set of integrations. Do not remove.
    default_config:
    
    # Load frontend themes from the themes folder
    frontend:
      themes: !include_dir_merge_named themes
    
    automation: !include automations.yaml
    script: !include scripts.yaml
    scene: !include scenes.yaml
    
    #http:
    #  ssl_certificate: /ssl/fullchain.pem
    #  ssl_key: /ssl/privkey.pem
    
    #cloudflare
    http:
      use_x_forwarded_for: true
      trusted_proxies:
        - 17*.**.**.0/24
    
    #api:
    
    alexa:
      smart_home:
    #    locale: en-US
    #    endpoint: https://api.amazonalexa.com/v3/events
    #    client_id: "****"    using id from Alexa Skill Messaging
    #    client_secret: "****"   using secret from Alexa Skill Messaging
    #    filter:
    #      include_entities:
    #        - automation.balkony_lights_turn_off
    #        - automation.balkony_lights_turn_on
    #        - script.lg_volume_down
    #        - script.lg_volume_up
    #        - sensor.a1_homebox_rx
    #      include_domains:
    #        - automation
    #        - scene
    #        - script
    #      exclude_domains:
    #        - binary_sensor
    #        - button
    #        - camera
    #        - group
    #        - switch
    #        - light
    #        - device_tracker
    #        - input_button
    #        - input_boolean
    #        - input_datetime
    #        - input_number
    #        - input_select
    #        - input_text
    #        - media_player
    #        - sensor
    #        - number
    #        - persistent_notification
    #        - person
    #        - select
    #        - sun
    #        - update
    #        - weather
    #        - zone

@JeredGeist @Squallzz @MaxWinterstein do you have any insights in where I could have gone wrong? Any kind of help is greatly appreciated. Thnx in advance.

@Squallzz
Copy link

Squallzz commented Jan 3, 2024

Still can't get this to bypass Cloudflare bot protect by itself. :( So far tried WAF custom rules, Client Certs, Configured Rules...... sighz
Had to create a WAF rule allowing: AS14618 and A16509. But it leaves a big block of access to bad actors bypassing Cloudflare protection that use these AS.

Is this needed to get it working?

I tried doing it without this WAF rules and I am stuck. I followed this guide to the letter, but I still get "Unable to link the skill at this time. Please try again later."

I have two cloudflare policies set up.

image

The "Default" one for restricting login to only my email address, and the service token auth one named "haaska".

image

Here is my function config

image

When I test my function I get this response (succeeded):

image

I tested with this code

{ "directive": { "header": { "namespace": "Alexa.Discovery", "name": "Discover", "payloadVersion": "3", "messageId": "1bd5d003-31b9-476f-ad03-71d471922820" }, "payload": { "scope": { "type": "BearerToken" } } } }

When I test my wrapper function with the same code I get this error:

image

Config of wrapper function:

image

Wrapper function url

image

HA configuration.yaml:

    # Loads default set of integrations. Do not remove.
    default_config:
    
    # Load frontend themes from the themes folder
    frontend:
      themes: !include_dir_merge_named themes
    
    automation: !include automations.yaml
    script: !include scripts.yaml
    scene: !include scenes.yaml
    
    #http:
    #  ssl_certificate: /ssl/fullchain.pem
    #  ssl_key: /ssl/privkey.pem
    
    #cloudflare
    http:
      use_x_forwarded_for: true
      trusted_proxies:
        - 172.30.33.0/24
    
    #api:
    
    alexa:
      smart_home:
    #    locale: en-US
    #    endpoint: https://api.amazonalexa.com/v3/events
    #    client_id: "****"    using id from Alexa Skill Messaging
    #    client_secret: "****"   using secret from Alexa Skill Messaging
    #    filter:
    #      include_entities:
    #        - automation.balkony_lights_turn_off
    #        - automation.balkony_lights_turn_on
    #        - script.lg_volume_down
    #        - script.lg_volume_up
    #        - sensor.a1_homebox_rx
    #      include_domains:
    #        - automation
    #        - scene
    #        - script
    #      exclude_domains:
    #        - binary_sensor
    #        - button
    #        - camera
    #        - group
    #        - switch
    #        - light
    #        - device_tracker
    #        - input_button
    #        - input_boolean
    #        - input_datetime
    #        - input_number
    #        - input_select
    #        - input_text
    #        - media_player
    #        - sensor
    #        - number
    #        - persistent_notification
    #        - person
    #        - select
    #        - sun
    #        - update
    #        - weather
    #        - zone

@JeredGeist @Squallzz @MaxWinterstein do you have any insights in where I could have gone wrong? Any kind of help is greatly appreciated. Thnx in advance.

Ill try to help since I had so many issues too.
CF Access > your stuff looks right..
pjsjsZT
The access token is the one made from Access >Service Auth

AWS > Lambda > HomeAssistant (Python 3.8, Alexa trigger)


Copyright 2019 Jason Hu <awaregit at gmail.com>

Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at

    http://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
"""
import os
import json
import logging
import urllib3

_debug = bool(os.environ.get('DEBUG'))

_logger = logging.getLogger('HomeAssistant-SmartHome')
_logger.setLevel(logging.DEBUG if _debug else logging.INFO)


def lambda_handler(event, context):
    """Handle incoming Alexa directive."""
    
    _logger.debug('Event: %s', event)

    base_url = os.environ.get('BASE_URL')
    assert base_url is not None, 'Please set BASE_URL environment variable'
    base_url = base_url.strip("/")
    
    _logger.debug("Base url: {}".format(base_url))

    directive = event.get('directive')
    assert directive is not None, 'Malformatted request - missing directive'
    assert directive.get('header', {}).get('payloadVersion') == '3', \
        'Only support payloadVersion == 3'
    
    scope = directive.get('endpoint', {}).get('scope')
    if scope is None:
        # token is in grantee for Linking directive 
        scope = directive.get('payload', {}).get('grantee')
    if scope is None:
        # token is in payload for Discovery directive 
        scope = directive.get('payload', {}).get('scope')
    assert scope is not None, 'Malformatted request - missing endpoint.scope'
    assert scope.get('type') == 'BearerToken', 'Only support BearerToken'

    token = scope.get('token')
    if token is None and _debug:
        token = os.environ.get('LONG_LIVED_ACCESS_TOKEN')  # only for debug purpose
    
    verify_ssl = not bool(os.environ.get('NOT_VERIFY_SSL'))
    
    cf_client_id = os.environ.get('CF_CLIENT_ID')
    cf_client_secret = os.environ.get('CF_CLIENT_SECRET')
    
    http = urllib3.PoolManager(
        cert_reqs='CERT_REQUIRED' if verify_ssl else 'CERT_NONE',
        timeout=urllib3.Timeout(connect=2.0, read=10.0)
    )
    
    response = http.request(
        'POST', 
        '{}/api/alexa/smart_home'.format(base_url),
        headers={
            'Authorization': 'Bearer {}'.format(token),
            'Content-Type': 'application/json',
            'CF-Access-Client-Id': cf_client_id,
            'CF-Access-Client-Secret': cf_client_secret,
        },
        body=json.dumps(event).encode('utf-8'),
    )
    if response.status >= 400:
        return {
            'event': {
                'payload': {
                    'type': 'INVALID_AUTHORIZATION_CREDENTIAL' 
                            if response.status in (401, 403) else "INTERNAL_ERROR {}".format(response.status),
                    'message': response.data.decode("utf-8"),
                }
            }
        }
    _logger.debug('Response: %s', response.data.decode("utf-8"))
    return json.loads(response.data.decode('utf-8'))

Test code:
{ "directive": { "header": { "namespace": "Alexa.Discovery", "name": "Discover", "payloadVersion": "3", "messageId": "1bd5d003-31b9-476f-ad03-71d471922820" }, "payload": { "scope": { "type": "BearerToken", "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiIzZjQ2ZDc3M2Q5MTM0NjZlOWNkMGE4YjY5YjBmNTMzYyIsImlhdCI6MTcwNDAxNjc4NiwiZXhwIjoyMDE5Mzc2Nzg2fQ._UBKoS_K22bk5BE9sctnF8xKjtgWv2IIPKuPmFKwquo" } } } } (test should be successful and show your devices)

AWS > Lambda > Wrapper class (Python 3.8, no trigger)
zsgUNV7
Wrapper class code
`import json
import logging
import os
import urllib3
import base64

_debug = bool(os.environ.get('DEBUG'))

_logger = logging.getLogger('HomeAssistant-SmartHome-Wrapper')
_logger.setLevel(logging.DEBUG if _debug else logging.INFO)

def lambda_handler(event, context):

cf_client_id = os.environ.get('CF_CLIENT_ID')
cf_client_secret = os.environ.get('CF_CLIENT_SECRET')

app_secret = os.environ.get('APP_SECRET')

destination_url = os.environ.get('BASE_URL')
assert destination_url is not None, 'Please set BASE_URL environment variable'
destination_url = destination_url.strip("/")

http = urllib3.PoolManager(
    cert_reqs='CERT_REQUIRED',
    timeout=urllib3.Timeout(connect=2.0, read=10.0)
)

# TODO: Check if the request has `isBase64Encoded` is set to true before
# doing the b64decode on this
req_body = base64.b64decode(event.get('body'))

_logger.debug(req_body)

headers = {
    'Content-Type': 'application/x-www-form-urlencoded',
    'CF-Access-Client-Id': cf_client_id,
    'CF-Access-Client-Secret': cf_client_secret
}

response = http.request(
    'POST', 
    '{}/auth/token'.format(destination_url),
    headers=headers,
    body=req_body
)

if response.status >= 400:
    _logger.debug("ERROR {} {}".format(response.status, response.data))
    return {
        'event': {
            'payload': {
                'type': 'INVALID_AUTHORIZATION_CREDENTIAL' 
                        if response.status in (401, 403) else "INTERNAL_ERROR {}".format(response.status),
                'message': response.data.decode("utf-8"),
            }
        }
    }
_logger.debug('Response: %s', response.data.decode("utf-8"))
return json.loads(response.data.decode('utf-8'))

`
Env variables:
APP_SECRET > HA LongLivedAccessToken
CF_CLIENT_ID > Cloudflare client ID, WITHOUT the header, so mine starts with "fd"
image
BASE_URL > the exposed https link to your HA instance, https://my.domain.com (no trailing slash, idk if it matters)

For the wrapper, you need to go to Configuration > Function URL and generate a new URL with auth type of NONE

Alexa developer console
Smart Home > Payload version v3 (preferred)
Default endpoint: arn for the HomeAssistant AWS Lambda, not the wrapper
Account Linking > Your Web Authorization URL: the publicly exposed https link for your HA instance
Account Linking > Access Token URL > The function URL created on your AWS Lambda wrapper class
Your Client ID > Region specific - the "pitangui" one is for US, I forget the others, at the bottom of the page
Your Secret > Doesnt matter
Your authorization scheme > HTTP Basic (Recommended)
Scope > smart_home

image

If you still get issues linking, try temporarily forwarding outside port 443 to internal port 8123 in your router, I use opnsense.

Good luck.

@Squallzz
Copy link

Squallzz commented Jan 3, 2024

Still can't get this to bypass Cloudflare bot protect by itself. :( So far tried WAF custom rules, Client Certs, Configured Rules...... sighz
Had to create a WAF rule allowing: AS14618 and A16509. But it leaves a big block of access to bad actors bypassing Cloudflare protection that use these AS.

Is this needed to get it working?

I tried doing it without this WAF rules and I am stuck. I followed this guide to the letter, but I still get "Unable to link the skill at this time. Please try again later."

I have two cloudflare policies set up.

image

The "Default" one for restricting login to only my email address, and the service token auth one named "haaska".

image

Here is my function config

image

When I test my function I get this response (succeeded):

image

I tested with this code

{ "directive": { "header": { "namespace": "Alexa.Discovery", "name": "Discover", "payloadVersion": "3", "messageId": "1bd5d003-31b9-476f-ad03-71d471922820" }, "payload": { "scope": { "type": "BearerToken" } } } }

When I test my wrapper function with the same code I get this error:

image

Config of wrapper function:

image

Wrapper function url

image

HA configuration.yaml:

    # Loads default set of integrations. Do not remove.
    default_config:
    
    # Load frontend themes from the themes folder
    frontend:
      themes: !include_dir_merge_named themes
    
    automation: !include automations.yaml
    script: !include scripts.yaml
    scene: !include scenes.yaml
    
    #http:
    #  ssl_certificate: /ssl/fullchain.pem
    #  ssl_key: /ssl/privkey.pem
    
    #cloudflare
    http:
      use_x_forwarded_for: true
      trusted_proxies:
        - 172.30.33.0/24
    
    #api:
    
    alexa:
      smart_home:
    #    locale: en-US
    #    endpoint: https://api.amazonalexa.com/v3/events
    #    client_id: "****"    using id from Alexa Skill Messaging
    #    client_secret: "****"   using secret from Alexa Skill Messaging
    #    filter:
    #      include_entities:
    #        - automation.balkony_lights_turn_off
    #        - automation.balkony_lights_turn_on
    #        - script.lg_volume_down
    #        - script.lg_volume_up
    #        - sensor.a1_homebox_rx
    #      include_domains:
    #        - automation
    #        - scene
    #        - script
    #      exclude_domains:
    #        - binary_sensor
    #        - button
    #        - camera
    #        - group
    #        - switch
    #        - light
    #        - device_tracker
    #        - input_button
    #        - input_boolean
    #        - input_datetime
    #        - input_number
    #        - input_select
    #        - input_text
    #        - media_player
    #        - sensor
    #        - number
    #        - persistent_notification
    #        - person
    #        - select
    #        - sun
    #        - update
    #        - weather
    #        - zone

@JeredGeist @Squallzz @MaxWinterstein do you have any insights in where I could have gone wrong? Any kind of help is greatly appreciated. Thnx in advance.

Like you, I was also unable to get the config variables working like this specific guide uses. I ended up just putting it all into configuration variables instead of the separate config.

@d20z3f
Copy link

d20z3f commented Jan 4, 2024

@Squallzz thnx for your reply.

I have successfully set up Alexa Skill linking😁🎉🎉🎉

Thank you soo much.

This is what I did:

I have used your codes for both lambda functions, and I have made progress.

The HA function now gives a successful result after testing, I can see my devices.

P.S. I found that I had a WAF Rule that allowed only access from my country (Croatia) but the functions and Alexa app have US setup, so after adding US to that rule, the tests work.

I had to change the token in your test code as it had to include my HA long live token:
{ "directive": { "header": { "namespace": "Alexa.Discovery", "name": "Discover", "payloadVersion": "3", "messageId": "1bd5d003-31b9-476f-ad03-71d471922820" }, "payload": { "scope": { "type": "BearerToken", "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiI1MDY4YjllOWVhOWI0ZWFjODYxNThjMTA0MGI3MTM1ZCIsImlhdCI6MTcwNDI4NjMzMiwiZXhwIjoyMDE5NjQ2MzMyfQ.2FeRbeNlNYrDUKknkmu8r0fc*****_OgZdRJkV4" } } } }

Your HA_wrapper function code gave me an error at line 30:
req_body = base64.b64decode(event.get('body'))`

Response { "errorMessage": "argument should be a bytes-like object or ASCII string, not 'NoneType'", "errorType": "TypeError", "stackTrace": [ " File \"/var/task/lambda_function.py\", line 30, in lambda_handler\n req_body = base64.b64decode(event.get('body'))\n", " File \"/var/lang/lib/python3.8/base64.py\", line 80, in b64decode\n s = _bytes_from_decode_data(s)\n", " File \"/var/lang/lib/python3.8/base64.py\", line 45, in _bytes_from_decode_data\n raise TypeError(\"argument should be a bytes-like object or ASCII \"\n" ] }

After changing it to
req_body = base64.b64decode(event.get('body')) if event.get('isBase64Encoded') else event.get('body')

the function test is now successful but with a INTERNAL_ERROR 400

image

For the wrapper function test I am using the same test code as for the regular HA function, so I don't know if I was supposed to get the 400 error.

For future reference this is the correct wrapper function code:

import json
import logging
import os
import urllib3
import base64

_debug = bool(os.environ.get('DEBUG'))

_logger = logging.getLogger('HomeAssistant-SmartHome-Wrapper')
_logger.setLevel(logging.DEBUG if _debug else logging.INFO)

def lambda_handler(event, context):

cf_client_id = os.environ.get('CF_CLIENT_ID')
cf_client_secret = os.environ.get('CF_CLIENT_SECRET')

app_secret = os.environ.get('APP_SECRET')

destination_url = os.environ.get('BASE_URL')
assert destination_url is not None, 'Please set BASE_URL environment variable'
destination_url = destination_url.strip("/")

http = urllib3.PoolManager(
    cert_reqs='CERT_REQUIRED',
    timeout=urllib3.Timeout(connect=2.0, read=10.0)
)

# TODO: Check if the request has `isBase64Encoded` is set to true before
# doing the b64decode on this
req_body = base64.b64decode(event.get('body')) if event.get('isBase64Encoded') else event.get('body')

_logger.debug(req_body)

headers = {
    'Content-Type': 'application/x-www-form-urlencoded',
    'CF-Access-Client-Id': cf_client_id,
    'CF-Access-Client-Secret': cf_client_secret
}

response = http.request(
    'POST', 
    '{}/auth/token'.format(destination_url),
    headers=headers,
    body=req_body
)

if response.status >= 400:
    _logger.debug("ERROR {} {}".format(response.status, response.data))
    return {
        'event': {
            'payload': {
                'type': 'INVALID_AUTHORIZATION_CREDENTIAL' 
                        if response.status in (401, 403) else "INTERNAL_ERROR {}".format(response.status),
                'message': response.data.decode("utf-8"),
            }
        }
    }
_logger.debug('Response: %s', response.data.decode("utf-8"))
return json.loads(response.data.decode('utf-8'))

Both functions have the same configuration Environment variables
image

For the Alexa developer console I followed your advice and set it up like this:
Smart Home > Payload version v3 (preferred)
Default endpoint: arn for the HomeAssistant AWS Lambda, not the wrapper
Account Linking > Your Web Authorization URL: the publicly exposed https link for your HA instance
Account Linking > Access Token URL > The function URL created on your AWS Lambda wrapper class
Your Client ID > Region specific - the "pitangui" one is for US, I forget the others, at the bottom of the page
Your Secret > Doesnt matter
Your authorization scheme > HTTP Basic (Recommended)
Scope > smart_home

image

@Squallzz
Copy link

Squallzz commented Jan 4, 2024

@Squallzz thnx for your reply.

I have successfully set up Alexa Skill linking😁🎉🎉🎉

Thank you soo much.

This is what I did:

I have used your codes for both lambda functions, and I have made progress.

The HA function now gives a successful result after testing, I can see my devices.

P.S. I found that I had a WAF Rule that allowed only access from my country (Croatia) but the functions and Alexa app have US setup, so after adding US to that rule, the tests work.

I had to change the token in your test code as it had to include my HA long live token: { "directive": { "header": { "namespace": "Alexa.Discovery", "name": "Discover", "payloadVersion": "3", "messageId": "1bd5d003-31b9-476f-ad03-71d471922820" }, "payload": { "scope": { "type": "BearerToken", "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiI1MDY4YjllOWVhOWI0ZWFjODYxNThjMTA0MGI3MTM1ZCIsImlhdCI6MTcwNDI4NjMzMiwiZXhwIjoyMDE5NjQ2MzMyfQ.2FeRbeNlNYrDUKknkmu8r0fc*****_OgZdRJkV4" } } } }

Your HA_wrapper function code gave me an error at line 30: req_body = base64.b64decode(event.get('body'))`

Response { "errorMessage": "argument should be a bytes-like object or ASCII string, not 'NoneType'", "errorType": "TypeError", "stackTrace": [ " File \"/var/task/lambda_function.py\", line 30, in lambda_handler\n req_body = base64.b64decode(event.get('body'))\n", " File \"/var/lang/lib/python3.8/base64.py\", line 80, in b64decode\n s = _bytes_from_decode_data(s)\n", " File \"/var/lang/lib/python3.8/base64.py\", line 45, in _bytes_from_decode_data\n raise TypeError(\"argument should be a bytes-like object or ASCII \"\n" ] }

After changing it to req_body = base64.b64decode(event.get('body')) if event.get('isBase64Encoded') else event.get('body')

the function test is now successful but with a INTERNAL_ERROR 400

image

For the wrapper function test I am using the same test code as for the regular HA function, so I don't know if I was supposed to get the 400 error.

For future reference this is the correct wrapper function code:

import json import logging import os import urllib3 import base64

_debug = bool(os.environ.get('DEBUG'))

_logger = logging.getLogger('HomeAssistant-SmartHome-Wrapper') _logger.setLevel(logging.DEBUG if _debug else logging.INFO)

def lambda_handler(event, context):

cf_client_id = os.environ.get('CF_CLIENT_ID')
cf_client_secret = os.environ.get('CF_CLIENT_SECRET')

app_secret = os.environ.get('APP_SECRET')

destination_url = os.environ.get('BASE_URL')
assert destination_url is not None, 'Please set BASE_URL environment variable'
destination_url = destination_url.strip("/")

http = urllib3.PoolManager(
    cert_reqs='CERT_REQUIRED',
    timeout=urllib3.Timeout(connect=2.0, read=10.0)
)

# TODO: Check if the request has `isBase64Encoded` is set to true before
# doing the b64decode on this
req_body = base64.b64decode(event.get('body')) if event.get('isBase64Encoded') else event.get('body')

_logger.debug(req_body)

headers = {
    'Content-Type': 'application/x-www-form-urlencoded',
    'CF-Access-Client-Id': cf_client_id,
    'CF-Access-Client-Secret': cf_client_secret
}

response = http.request(
    'POST', 
    '{}/auth/token'.format(destination_url),
    headers=headers,
    body=req_body
)

if response.status >= 400:
    _logger.debug("ERROR {} {}".format(response.status, response.data))
    return {
        'event': {
            'payload': {
                'type': 'INVALID_AUTHORIZATION_CREDENTIAL' 
                        if response.status in (401, 403) else "INTERNAL_ERROR {}".format(response.status),
                'message': response.data.decode("utf-8"),
            }
        }
    }
_logger.debug('Response: %s', response.data.decode("utf-8"))
return json.loads(response.data.decode('utf-8'))

Both functions have the same configuration Environment variables image

For the Alexa developer console I followed your advice and set it up like this: Smart Home > Payload version v3 (preferred) Default endpoint: arn for the HomeAssistant AWS Lambda, not the wrapper Account Linking > Your Web Authorization URL: the publicly exposed https link for your HA instance Account Linking > Access Token URL > The function URL created on your AWS Lambda wrapper class Your Client ID > Region specific - the "pitangui" one is for US, I forget the others, at the bottom of the page Your Secret > Doesnt matter Your authorization scheme > HTTP Basic (Recommended) Scope > smart_home

image

Awesome, glad you got it working. I had to combine multiple sets of information from different posts to get it working.. I’m pretty sure the GitHub code formatting probably broke the stuff I sent, it was all working in my live when I posted it

@schmjop
Copy link

schmjop commented Feb 25, 2024

Thank you so much. Worked directly after following this Guide! 👍

@johndoe0815
Copy link

johndoe0815 commented Apr 29, 2024

Where do I set this ServiceAuth policy? I am searching all around Cloudflare, but do not find it.
It talks about applications, but I do not have an "Application" set up in Cloudflare. I just have a Tunnel with all the routes from subdomains to my different internal services.

EDIT: Turns out that I didn't even need of all that.
I was switching from previously duckdns for Alexa to now also Cloudflare for this scenario.
I have just tunnels set up, as mentioned. Alexa integration is set up as per official docu, just exchanged not the URL.
In the Alexa app, I then had to deactivate the skill and reactivate it, so that the logon page appeared again and I could logon.
No long-lived token, no code adjustments.
At least it looks like everything is working again as before (status is synchronized between alexa and HA, switching via voice or alexa app possible).

@jmcruvellier
Copy link

Thanks you so much! I was close to give up, but by strictly following your instructions I succeeded!!!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment