I followed these documents:
https://aws.amazon.com/blogs/mobile/appsync-websockets-python/ https://docs.aws.amazon.com/general/latest/gr/sigv4-signed-request-examples.html
to make a python websocket client to subscribe to aws appsync using IAM authorization. It's worth mentioning that I managed to get IAM authorization to work with the mutation client, but using the same implementation and IAM user does not work with the subscription client. Here is the code for the subscription client:
# subscription_client.py
from base64 import b64encode, decode
from datetime import datetime
from uuid import uuid4
import websocket, hashlib, hmac, datetime
import threading
import json
# Constants Copied from AppSync API 'Settings'
API_URL = "***"
receiverId = "***"
# GraphQL subscription Registration object
request_parameters = json.dumps({
'query': 'subscription MySubscription { receivedMessage(receiverId: "'+ receiverId +'") {receiverId}}',
'variables': {}
})
# Discovered values from the AppSync endpoint (API_URL)
WSS_URL = API_URL.replace('https','wss').replace('appsync-api','appsync-realtime-api')
HOST = API_URL.replace('https://','').replace('/graphql','')
# Subscription ID (client generated)
SUB_ID = str(uuid4())
# Set up Timeout Globals
timeout_timer = None
timeout_interval = 10
# Calculate UTC time in ISO format (AWS Friendly): YYYY-MM-DDTHH:mm:ssZ
def header_time():
return datetime.utcnow().isoformat(sep='T',timespec='seconds') + 'Z'
# Encode Using Base 64
def header_encode( header_obj ):
return b64encode(json.dumps(header_obj).encode('utf-8')).decode('utf-8')
# reset the keep alive timeout daemon thread
def reset_timer( ws ):
global timeout_timer
global timeout_interval
if (timeout_timer):
timeout_timer.cancel()
timeout_timer = threading.Timer( timeout_interval, lambda: ws.close() )
timeout_timer.daemon = True
timeout_timer.start()
# Create IAM credentials header
def sign(key, msg):
return hmac.new(key, msg.encode("utf-8"), hashlib.sha256).digest()
def getSignatureKey(key, date_stamp, regionName, serviceName):
kDate = sign(('AWS4' + key).encode('utf-8'), date_stamp)
kRegion = sign(kDate, regionName)
kService = sign(kRegion, serviceName)
kSigning = sign(kService, 'aws4_request')
return kSigning
t = datetime.datetime.utcnow()
amz_date = t.strftime('%Y%m%dT%H%M%SZ')
date_stamp = t.strftime('%Y%m%d')
access_key = os.environ.get('AWS_ACCESS_KEY_ID')
secret_key = os.environ.get('AWS_SECRET_ACCESS_KEY')
canonical_uri = '/graphql/connect'
region = 'eu-central-1'
service = 'appsync'
canonical_querystring = ''
method = 'POST'
content_type = 'application/json; charset=UTF-8'
canonical_headers = 'content-type:' + content_type + '\n' + 'host:' + HOST + '\n' + 'x-amz-date:' + amz_date + '\n'
signed_headers = 'content-type;host;x-amz-date'
payload_hash = hashlib.sha256(request_parameters.encode('utf-8')).hexdigest()
canonical_request = method + '\n' + canonical_uri + '\n' + canonical_querystring + '\n' + canonical_headers + '\n' + signed_headers + '\n' + payload_hash
algorithm = 'AWS4-HMAC-SHA256'
credential_scope = date_stamp + '/' + region + '/' + service + '/' + 'aws4_request'
string_to_sign = algorithm + '\n' + amz_date + '\n' + credential_scope + '\n' + hashlib.sha256(canonical_request.encode('utf-8')).hexdigest()
signing_key = getSignatureKey(secret_key, date_stamp, region, service)
signature = hmac.new(signing_key, (string_to_sign).encode('utf-8'), hashlib.sha256).hexdigest()
authorization_header = algorithm + ' ' + 'Credential=' + access_key + '/' + credential_scope + ', ' + 'SignedHeaders=' + signed_headers + ', ' + 'Signature=' + signature
iam_header = {
"accept": "application/json, text/javascript",
"content-encoding": "amz-1.0",
"content-type": "application/json; charset=UTF-8",
"host":HOST,
"x-amz-date": amz_date,
"Authorization": authorization_header
}
# Socket Event Callbacks, used in WebSocketApp Constructor
def on_message(ws, message):
global timeout_timer
global timeout_interval
print('### message ###')
print('<< ' + message)
message_object = json.loads(message)
message_type = message_object['type']
if( message_type == 'ka' ):
reset_timer(ws)
elif( message_type == 'connection_ack' ):
timeout_interval = int(json.dumps(message_object['payload']['connectionTimeoutMs']))
register = {
'id': SUB_ID,
'payload': {
'data': request_parameters,
'extensions': {
'authorization': {
"accept": "application/json, text/javascript",
"content-encoding": "amz-1.0",
"content-type": "application/json; charset=UTF-8",
"host":HOST,
"x-amz-date": amz_date,
"Authorization": authorization_header
}
}
},
'type': 'start'
}
start_sub = json.dumps(register)
print('>> '+ start_sub )
ws.send(start_sub)
elif(message_object['type'] == 'error'):
print ('Error from AppSync: ' + message_object['payload'])
def on_error(ws, error):
print('### error ###')
print(error)
def on_close(ws):
print('### closed ###')
def on_open(ws):
print('### opened ###')
init = {
'type': 'connection_init'
}
init_conn = json.dumps(init)
print('>> '+ init_conn)
ws.send(init_conn)
if __name__ == '__main__':
# Uncomment to see socket bytestreams
#websocket.enableTrace(True)
# Set up the connection URL, which includes the Authentication Header
# and a payload of '{}'. All info is base 64 encoded
connection_url = WSS_URL + '?header=' + header_encode(iam_header) + '&payload=e30='
# Create the websocket connection to AppSync's real-time endpoint
# also defines callback functions for websocket events
# NOTE: The connection requires a subprotocol 'graphql-ws'
print( 'Connecting to: ' + connection_url )
ws = websocket.WebSocketApp( connection_url,
subprotocols=['graphql-ws'],
on_open = on_open,
on_message = on_message,
on_error = on_error,
on_close = on_close,)
ws.run_forever()
And here is the output containing the error when I run the script:
(aws) ➜ appsync-websockets-python git:(master) ✗ python subscription_client.py
Connecting to: wss://4akngv4mibcr3jz6ffzu3vcnji.appsync-realtime-api.eu-central-1.amazonaws.com/graphql?header=eyJhY2NlcHQiOiAiYXBwbGljYXRpb24vanNvbiwgdGV4dC9qYXZhc2NyaXB0IiwgImNvbnRlbnQtZW5jb2RpbmciOiAiYW16LTEuMCIsICJjb250ZW50LXR5cGUiOiAiYXBwbGljYXRpb24vanNvbjsgY2hhcnNldD1VVEYtOCIsICJob3N0IjogIjRha25ndjRtaWJjcjNqejZmZnp1M3ZjbmppLmFwcHN5bmMtYXBpLmV1LWNlbnRyYWwtMS5hbWF6b25hd3MuY29tIiwgIngtYW16LWRhdGUiOiAiMjAyMTA1MjhUMDkzODMzWiIsICJBdXRob3JpemF0aW9uIjogIkFXUzQtSE1BQy1TSEEyNTYgQ3JlZGVudGlhbD1BS0lBNUxNQVdJMjZNSUw3N0VIQi8yMDIxMDUyOC9ldS1jZW50cmFsLTEvYXBwc3luYy9hd3M0X3JlcXVlc3QsIFNpZ25lZEhlYWRlcnM9Y29udGVudC10eXBlO2hvc3Q7eC1hbXotZGF0ZSwgU2lnbmF0dXJlPTA4ZjUzNWE0N2M0ZGI1MmQ3YWE3ODU3Mzc1MjZlYTJmN2Q0YjY2NWY2NGViYTYwYzM2Mjk3YjU4NmFjZDZmNDgifQ==&payload=e30=
### opened ###
>> {"type": "connection_init"}
### message ###
<< {"payload":{"errors":[{"errorType":"com.amazonaws.deepdish.graphql.auth#BadRequestException","message":"The request signature we calculated does not match the signature you provided. Check your AWS Secret Access Key and signing method. Consult the service documentation for details.\n\nThe Canonical String for this request should have been\n'POST\n/graphql/connect\n\ncontent-type:application/json; charset=UTF-8\nhost:4akngv4mibcr3jz6ffzu3vcnji.appsync-api.eu-central-1.amazonaws.com\nx-amz-date:20210528T093833Z\n\ncontent-type;host;x-amz-date\n44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a'\n\nThe String-to-Sign should have been\n'AWS4-HMAC-SHA256\n20210528T093833Z\n20210528/eu-central-1/appsync/aws4_request\n1bb8f8f7e4a68a2b3ee51853ca2280b25fea64cdf5b5958f38e918f09beaa65f'\n","errorCode":400}]},"type":"connection_error"}
### error ###
Connection is already closed.
### closed ###