🔥Building a Teams Bot with AI Capabilities - Part 4 - Receiver Lambda for OAuth2 Tokens and State🔥
aka, I don't want to authenticate to SSO each time I send you a message
This blog series focuses on presenting complex DevOps projects as simple and approachable via plain language and lots of pictures. You can do it!
These articles are supported by readers, please consider subscribing to support me writing more of these articles <3 :)
This article is part of a series of articles, because 1 article would be absolutely massive.
Part 3: Delegated Permissions and Making Lambda Stateful for Oauth2
Part 4 (this article): Building the Receiver lambda to store tokens and state
Hey all!
In the series so far we’ve registered an App Registration (permissions), an Azure Bot (Teams back-end Bot infra and link to permissions), and built a bot in the Teams Developer Portal (register name in Teams App). We also talked about how we’ll be building this Teams app with Delegated access tokens exclusively, which means we’ll need to establish some state for the tokens and conversations.
The use case for storing those two things are very different:
Storing Conversations - We only have one function URL, so we need some routing - on first contact, we’ll push a “Card” to Teams to send users to the SSO login portal, and when the OAuth2 token is pushed to us, we’ll do our AI stuff. Since the first instance has shut down and discarded state, we’ll need to store the first contact payload to resume it when we have the token on second run!
Storing Tokens - Since our app is stateless, once we receive the token and run, the lambda shuts off and we lose the token. If we store it for next run, users don’t need to provide a token on each run!
We’ll go over this in more depth as we walk through the code.
If you don’t care about the walk-through, and would rather just skip to the codebase, we’ll be walking through this Receiver lambda code:
github.com/KyMidd/TeamsAIBot/blob/master/lambda/src/receiver.py
We’ll have one lambda handle several different logic paths:
Teams event inbound, we don’t have a token (or token is expired)
Build “Card”, push to user
Store Conversations for pickup when token received
Direct them to SSO so they can authorize an OAuth2 token for us
Teams event inbound, we have a token
Build payload, pass to Worker
OAuth2 token inbound
Store token
Find resumed Conversation and build payload around it
Pass to Worker
Lets talk about the first one first (as it should be)
Handle a Teams Call, No Token Yet
On the very first call from a user, we don’t have a token for them.
First let’s store some constants.
On line 6, the secret the bot uses to store secrets. It should store the slack bot token and signing secret so we can decode the webhook and validate it’s… well, valid.
On line 9, the redirect function URL. We are unable to look this up due to circular logic - we can’t get it until it’s built, so terraform won’t let us do that. You can deploy this with an invalid value, just redeploy once you get a real redirect function URL. This is used to build the “Card” we’ll push to Teams users, and build a URL for redirection for the OAuth2 token.
On line 12, a CMK KMS key alias - an SSH key we’ll use to encrypt the OAuth2 tokens we get from users. These tokens can do a bunch of actions AS the user, so we need to be careful when storing them in dynamo to pass them between lambdas.
### | |
# Constants | |
### | |
# Secrets manager secret name. Json payload should contain SLACK_BOT_TOKEN and SLACK_SIGNING_SECRET | |
bot_secret_name = "YOUR_JSON_SECRET_NAME" # Change this to your actual secret name in AWS Secrets Manager | |
# Receiver lambda info (meta) | |
redirect_function_url = "https://XXXXXXX.lambda-url.us-east-1.on.aws" # Change this to your actual Lambda URL | |
# CMK Key, used to encrypt the token | |
cmk_key_alias = os.environ.get("CMK_ALIAS") |
Receive a Teams Event
Lets start with the main handler for the lambda event - we receive the event and context from AWS when the function URL is triggered.
On line 4, we check if the event path contains /callback. If yes, this is an oauth2 token. It’s not in this use case, so skip.
We check if channelData is part of the body on line 7 - that’s one of the Teams keys. If yes, we’re working a teams event.
Print out some debugs and then call the handle_teams_event on line 17.
# Main lambda handler | |
def lambda_handler(event, context): | |
# Check if the event POSTED to URI /callback | |
if event.get("rawPath") == "/callback": | |
# ... | |
# Check if channelData top level key is present. If yes, this is a Teams event | |
if 'channelData' in body: | |
# Debug | |
if os.environ.get("VERA_DEBUG") == "True": | |
print("🟢 Event body:", body) | |
try: | |
print("🟢 Teams event detected") | |
# Handle the teams event | |
handle_teams_event(body, event) | |
except Exception as error: | |
print("🚫 Error handling Teams event: %s", str(error)) |
The handle teams event is used to handle any Teams event that comes in, token present or not.
On line 11 and 12, we store the conversation table arn and token table arn as local vars - they’re injected as environmental variables by the terraform code.
Then on line 15 we register a dynamoDB client using the boto3 library - we’ll use this to read and write to dynamo.
Then on line 18 we read the aadObjectId from the event - this is the user that triggered the action. If the action came from Teams, it was triggered by a User or Bot, both of which are registered in Entra.
Then on line 25, we grab an encrypted token from dynamo. We have some error handling logic present in case there is no token. Lets zoom in on that.
def handle_teams_event(body, event): | |
# Identify event_type | |
event_type = body.get("type", "") | |
# Only process events we care about | |
if event_type == 'message': | |
print("🟢 Event type:", event_type) | |
# Read table names from environment variables | |
conversation_table_arn = os.environ.get("CONVERSATION_TABLE_ARN") | |
token_table_arn = os.environ.get("TOKEN_TABLE_ARN") | |
# Check for existing valid token | |
dynamodb_client = boto3.client("dynamodb") | |
# Get AAD ID from event | |
aadObjectId = body.get("from", {}).get("aadObjectId", "") | |
if not aadObjectId: | |
print("🚫 No AAD ID found in event, exiting") | |
return | |
# Check if existing token. If not, send user card to send to authentication | |
# Token is base64 encoded and also encrypted with a CMK key | |
encrypted_token = get_token(dynamodb_client, aadObjectId, token_table_arn) |
Yeah, But When Did I Cook That Token?
This function helps us get a token and validate that it’s valid for at least 30 seconds.
Expert tip: tokens are valid for between 60-90m, and it’s random for each token issued to avoid thundering herds expiring. Cool architecture, $MSFT.
On line 6, we attempt to get the item from dynamo that matches our “token” dynamo, and has a key (line 8) of the aadObjectId (the user’s ID) set to a string of the aadObjectId from the event. This should make sure that users can only fetch and use their own token. Notably if there is no token, $item is simply not populated.
On line 15, we check if there’s no item set (there was no token ever stored), and if so, we return None. We have some higher level checks that set next steps.
On line 19, we lookup when the item expiresAt, an epic time-stamp.
On line 22, we check if the token expires less than 30 seconds from now. I picked 30 seconds as a common sense default of how long the whole conversation takes to run. If the token expires 31 seconds from now, we probably don’t care, and can complete this conversation before then. If it expires less than 30 seconds from now (or is already expired), we return None.
If we get to line 29, there is a valid token, so we map it to a variable, $token, and return it.
Initially these tokens weren’t encrypted, but when I learned how powerful they were I decided to encrypt them (AES-256 using CMK KMS). We’ll talk about that soon. In future might move them to a Secrets Manager Secret…
def get_token(dynamodb_client, aadObjectId, table_name): | |
# Debug | |
if os.environ.get("VERA_DEBUG") == "True": | |
print("🟢 Looking for token for AAD ObjectId:", aadObjectId) | |
item = dynamodb_client.get_item( | |
TableName=table_name, | |
Key={"aadObjectId": {"S": aadObjectId}} | |
).get("Item") | |
# Debug | |
if os.environ.get("VERA_DEBUG") == "True": | |
print("🟢 DynamoDB item found:", item) | |
if not item: | |
return None | |
# Check when the token expires | |
expires_at = int(item["expiresAt"]["N"]) | |
# If token expires less than 30 seconds from now, return None | |
if expires_at - int(time.time()) <= 30: | |
# Debug | |
if os.environ.get("VERA_DEBUG") == "True": | |
print("🟡 Token is expired or expiring in the next 30 seconds, returning None") | |
return None | |
# Store the token in a variable | |
token = item["accessToken"]["S"] | |
return token |
We Found a Token - Awesome
Lets jump back to our handle_teams_event function. We’ve now either validated a token doesn’t exist ($encrypted_token == None) or it does ($encrypted_token == valid token).
We first run the logic of if there is a valid token (line 6), then we need to kick off the Worker to do the AI magic, so we register a lambda client on line 8.
Then on line 12, we add a key of “token” set to the encrypted token.
On line 15, we invoke the worker lambda - we look up the name of the Worker as an environment var injected by terraform.
Then on line 23 we return, closing out the run.
def handle_teams_event(body, event): | |
# ... | |
encrypted_token = get_token(dynamodb_client, aadObjectId, token_table_arn) | |
# If unexpired token is found, send it to the processor lambda | |
if encrypted_token: | |
# Initialize AWS Lambda client | |
lambda_client = boto3.client('lambda') | |
# Prepare the event to send to the processor lambda | |
# Add the token to the event | |
event["token"] = encrypted_token | |
# Asynchronously invoke the processor Lambda | |
lambda_client.invoke( | |
FunctionName=os.environ['WORKER_LAMBDA_NAME'], | |
InvocationType='Event', # Async invocation | |
Payload=json.dumps(event) | |
) | |
# Return | |
print("🟢 Successfully invoked the processor Lambda with the access token") | |
return |
No Token - No Problem
However, if we didn’t find a token in the dynamo table, we need to do two things:
Store the Conversation in the conversation dynamoDB table
Push an SSO Card to the Teams user.
Lets jump back to the handle_teams_event where we do that.
We’re working with dynamo again, so we register a client, line 6.
Then on line 7, we put the conversation payload into the conversation dynamo table, and set the aadObjectId as the user’s object ID. This is helpfully a string that’s present in the OAuth2 token that we’ll get in a second when the user authenticates, and we can resume their conversation.
def handle_teams_event(body, event): | |
# ... | |
if event_type == 'message': | |
# ... | |
# Store the conversation ID in DynamoDB | |
dynamodb_client = boto3.client("dynamodb") | |
dynamodb_client.put_item( | |
TableName=conversation_table_arn, | |
Item={ | |
"aadObjectId": {"S": aadObjectId}, | |
"event": {"S": json.dumps(event)}, # Store the entire event | |
} | |
) |
Then we need to build the SSO Card, but we need some information to do that.
We’ll grab our secrets from secrets manager using the get_secret_ssm_layer function - this is the same as I’ve covered previously in the AWS series, so I won’t cover it here. We use the lambda secrets manager AWS layer (it’s the fastest method!)
Then we load the secrets as json and disambiguate the different variables, line 9-12.
Then we get the bot bearer token that we use to send messages. We’ll use this to send the SSO Card in a second.
Lets dig into that.
def handle_teams_event(body, event): | |
# ... | |
if event_type == 'message': | |
# ... | |
# Fetch secret package | |
secrets = get_secret_ssm_layer(bot_secret_name) | |
# Disambiguate secrets | |
secrets_json = json.loads(secrets) | |
TENANT_ID = secrets_json["TENANT_ID"] | |
CLIENT_ID = secrets_json["CLIENT_ID"] | |
CLIENT_SECRET = secrets_json["CLIENT_SECRET"] | |
# Now we can use the bot token and signing secret | |
print("🟢 Successfully retrieved secrets from AWS Secrets Manager") | |
# Get bearer token for the bot to use to post messages | |
bot_bearer_token = get_teams_bearer_token(TENANT_ID, CLIENT_ID, CLIENT_SECRET) |
This function grabs us a bot bearer token - this lets us post messages to Teams as our bot user using the BotFramework.
We set our token URL on line 4.
Expert tip - this is different for a “global” Bot vs a “regional” Bot. Generally, you should be building a Global bot - “regional” is an old standard that isn’t used much anymore.
Then on line 7 we set the scope - the permissions we want. The “.default” scope grabs all permissions available. In some use cases you’d prune permissions based on user or workload, but this Bot is only used for this workload, so we want all the permissions.
Then on line 10 we build the payload - our client ID and secret (fetched from secrets manager) and our scope (permissions to ask for).
We use the requests library to send it, line 18, and check the status response on line 19. If it fails, we throw the error.
On line 22, we extract our access_token - a token we’ll use to send messages, and then return it on line 24.
def get_teams_bearer_token(TENANT_ID, CLIENT_ID, CLIENT_SECRET): | |
# Token endpoint for Azure AD - multi tenant | |
token_url = f"https://login.microsoftonline.com/botframework.com/oauth2/v2.0/token" | |
# Bot Framework requires this scope | |
scope = "https://api.botframework.com/.default" | |
# Build the request | |
payload = { | |
"grant_type": "client_credentials", | |
"client_id": CLIENT_ID, | |
"client_secret": CLIENT_SECRET, | |
"scope": scope, | |
} | |
# Request the token | |
response = requests.post(token_url, data=payload) | |
response.raise_for_status() # This will throw an error if the request fails | |
# Extract the token | |
bearer_token = response.json()["access_token"] | |
return bearer_token |
Build an IdP OAuth2 Redirect URL
Next we’ll build the auth URL - I built a function just for this because it’s so complex. Let’s zoom in on that.
On line 6 we set the scope of permissions we want (.default), which is all of them.
Then we call the function to build our OAuth URL.
def handle_teams_event(body, event): | |
# ... | |
if event_type == 'message': | |
# ... | |
# Build oauth url | |
scope = "https://graph.microsoft.com/.default" | |
auth_url = build_oauth_url(TENANT_ID, CLIENT_ID, aadObjectId, scope) |
We need to build an OAuth URL. That’s the VERY long URL that we’ll send to the user as a clickable button in the SSO Card. It’ll take them to the Microsoft Entra SSO (or whatever IdP you use). It needs a lot of very precise information.
On line 2, we start with a base URL - we need users to authenticate to Entra in our tenant, so we build that URL.
The URI is “/callback” - that’s our top-level URI indicator that the OAuth token is sent to our lambda.
Next up, we want to define a map of our query parameters - these are information that are going to be embedded in our URL:
The client ID of our caller
The response_type of “code”
The redirect URI - to tell the IdP (Entra) where to send the OAuth2 token
The scope/permissions we want (again, .default for all permissions, this time for the Graph API)
The “state” which is the user’s aad_object_id
Then we encode the whole thing in valid http using the urlencode() library, line 17, and return it, line 23.
def build_oauth_url(tenant_id, client_id, aad_object_id, scope): | |
base_url = f"https://login.microsoftonline.com/{tenant_id}/oauth2/v2.0/authorize" | |
# Build redirect URI | |
redirect_uri = f"{redirect_function_url}/callback" | |
query_params = { | |
"client_id": client_id, | |
"response_type": "code", | |
"redirect_uri": redirect_uri, | |
"response_mode": "query", | |
"scope": scope, | |
"state": aad_object_id | |
} | |
# Encode the query parameters | |
oauth_url = f"{base_url}?{urlencode(query_params)}" | |
# Debug | |
if os.environ.get("VERA_DEBUG") == "True": | |
print("🔗 OAuth URL encoded:", oauth_url) | |
return oauth_url |
Back to our handle_teams_event function - now that we have the OAuth2 URL, we need to build a Teams Card to push to the user. We also have a function for that.
def handle_teams_event(body, event): | |
# ... | |
if event_type == 'message': | |
# ... | |
# Format the OAuth card | |
card = format_oauth_card(auth_url) |
Build a Clickable SSO Card
This function builds a Teams Card that looks like the following:
The button the users can click takes them to the SSO portal with the URL we just built.
There’s not a lot here - we’re building a few text blocks that contain the weight and layout of our card, as well as a button that opens a URL, which is the Entra SSO sign-in.
We return it to the parent caller.
def format_oauth_card(auth_url): | |
return { | |
"type": "message", | |
"attachments": [ | |
{ | |
"contentType": "application/vnd.microsoft.card.adaptive", | |
"content": { | |
"$schema": "http://adaptivecards.io/schemas/adaptive-card.json", | |
"type": "AdaptiveCard", | |
"version": "1.5", | |
"body": [ | |
{ | |
"type": "TextBlock", | |
"text": "🔐 Sign in to authorize this Bot", | |
"weight": "Bolder", | |
"size": "Medium" | |
}, | |
{ | |
"type": "TextBlock", | |
"text": "To continue, please sign in with your Microsoft account.", | |
"wrap": True | |
} | |
], | |
"actions": [ | |
{ | |
"type": "Action.OpenUrl", | |
"title": "Sign in with Microsoft", | |
"url": auth_url | |
} | |
] | |
} | |
} | |
] | |
} |
Here’s My Business Card
Back to our teams handler. We need to send the SSO Card to the user as a Bot post.
We identify a few required items, like the response_url (the URL we can POST a response in the same conversation to), from 2 precursor items, both provided in our Teams webhook, line 6-8.
If we get this far (line 11), we create our headers using the bot bearer token and post the card to the User, line 16.
We check for status on line 22, and throw an error if it failed. If all worked, we return, line 26.
def handle_teams_event(body, event): | |
# ... | |
if event_type == 'message': | |
# ... | |
# Find the service URL and conversation ID from the event body | |
service_url = body["serviceUrl"] | |
conversation_id = body["conversation"]["id"] | |
response_url = f"{service_url}/v3/conversations/{conversation_id}/activities" | |
# Send the card to the user | |
if response_url: | |
headers = { | |
"Authorization": f"Bearer {bot_bearer_token}", | |
"Content-Type": "application/json" | |
} | |
response = requests.post(response_url, headers=headers, json=card) | |
# Read VERA_DEBUG from environment variable | |
if os.environ.get("VERA_DEBUG") == "True": | |
print("🟢 Response from sending auth card:", response.text) | |
response.raise_for_status() | |
print("🟢 Auth card sent successfully") | |
# All done, return | |
return |
That concludes the “Teams” workflow.
However! We haven’t handled the OAuth2 routing yet. If we receive a token from Entra, what do we do with it? Let’s cover that next.
Auth Code POST from Entra IdP
Now, what if Entra sends us a token? Well, it doesn’t. It does however, send us an “auth_token” that we can exchange for a token (big sigh, $MSFT). Lets get to it.
When the user approves the SSO login, Microsoft Entra IdP will send an auth_token payload to us on the /callback URI, so we check for that on line 4.
We look up the auth code, line 8 (yay!), as well as the aad_object_id (user’s Entra ID), line 9.
Then we build an auto-close page. We do this because the User - like, the real human user, gets redirected to our lambda function URL once they approve the SSO IdP issuance. That’s kinda weird, but we can handle it by giving them a javascript payload that’ll redirect them back to Teams and attempt to close their tab.
And zooooooming in.
def lambda_handler(event, context): | |
# ... | |
# Check if the event POSTED to URI /callback | |
if event.get("rawPath") == "/callback": | |
print("🟢 Received callback event") | |
# Set values to vars | |
auth_code = event["queryStringParameters"].get("code") | |
aad_object_id = event["queryStringParameters"].get("state") | |
# Debug | |
if os.environ.get("VERA_DEBUG") == "True": | |
print("🟢 Auth Code:", auth_code) | |
print("🟢 State (which is aad_object_id):", aad_object_id) | |
# Build auto-close page for user | |
autoclose_page = build_autoclose_page() |
SSO, Then Magically Back to Teams
This function builds an html payload with a script that redirects users to Teams, using the msteams handler. In practice, this means that after users approve the SSO prompt in their browser, their computer automatically switches back to the Teams app, which is conveniently now working on a response to their request.
It’s a really slick system.
def build_autoclose_page(): | |
html = """ | |
<!DOCTYPE html> | |
<html> | |
<head> | |
<title>Authentication Complete</title> | |
<script> | |
window.onload = function() { | |
if (window.opener) { | |
window.opener.postMessage("authComplete", "*"); | |
} | |
window.close(); | |
setTimeout(function() { | |
window.location.href = "msteams://teams.microsoft.com"; | |
}, 1000); | |
}; | |
</script> | |
</head> | |
<body> | |
<p>Successfully logged in, you can close this window.</p> | |
</body> | |
</html> | |
""" | |
# Return the html | |
return html |
Once we receive the html payload, we do all the auth code magic. We’ll dig into that shortly, but this function is almost complete, so lets look a bit ahead.
On line 9, we send back an http/200 and close out the page for the user.
I initially had this return happen before I worked the callback, but when lambdas send a response, they end, so I need to do the quick work of handling the auth code exchange and kicking on the Worker before we do that. In practice, that means users hang on the SSO approval screen for a second or two before the redirect loads. They barely notice.
Lets zoom in on how we handle the auth code callback.
def lambda_handler(event, context): | |
# ... | |
# Check if the event POSTED to URI /callback | |
if event.get("rawPath") == "/callback": | |
# Handle the "auth code" callback event when we receive the auth code from $MSFT | |
handle_auth_code_callback(body, event, auth_code, aad_object_id) | |
# Return the HTML page to the user | |
return { | |
'statusCode': 200, | |
'headers': { | |
'Content-Type': 'text/html' | |
}, | |
'body': autoclose_page | |
} |
Lets walk through how we handle an auth token from Entra.
We look up our table ARNs, line 4-5, and fetch our secret package, line 9, then disambiguate them lines 12-15. I won’t dig into this, because we did earlier.
On line 21, we do the token exchange - an auth code for an auth token that’s valid for Graph API.
def handle_auth_code_callback(body, event, auth_code, aad_object_id): | |
# Read table names from environment variables | |
conversation_table_arn = os.environ.get("CONVERSATION_TABLE_ARN") | |
token_table_arn = os.environ.get("TOKEN_TABLE_ARN") | |
# Get the bot token and signing secret from AWS Secrets Manager | |
# Fetch secret package | |
secrets = get_secret_ssm_layer(bot_secret_name) | |
# Disambiguate secrets | |
secrets_json = json.loads(secrets) | |
TENANT_ID = secrets_json["TENANT_ID"] | |
CLIENT_ID = secrets_json["CLIENT_ID"] | |
CLIENT_SECRET = secrets_json["CLIENT_SECRET"] | |
# Now we can use the bot token and signing secret | |
print("🟢 Successfully retrieved secrets from AWS Secrets Manager") | |
# Exchange the authorization code for an access token | |
token_response = exchange_code_for_token(auth_code, TENANT_ID, CLIENT_ID, CLIENT_SECRET) |
Finally, The Actual Token
Exchanging the code for a token is surprisingly straight-forward. We send over the required information, we get a token back. This token is issued for the user that’s using the bot, so we can operate as that user temporarily, which is pretty cool.
def exchange_code_for_token(auth_code, tenant_id, client_id, client_secret): | |
token_url = f"https://login.microsoftonline.com/{tenant_id}/oauth2/v2.0/token" | |
data = { | |
"client_id": client_id, | |
"client_secret": client_secret, | |
"scope": "https://graph.microsoft.com/.default", | |
"code": auth_code, | |
"redirect_uri": f"{redirect_function_url}/callback", | |
"grant_type": "authorization_code", | |
} | |
response = requests.post(token_url, data=data) | |
response.raise_for_status() | |
return response.json() |
Keep This Token in Your Pocket, Please
We receive the real, actual token (PHEW), so we store it and the expiration time (remember, it’s randomized so we can’t just do math), and calculate the expiration in epic time (seconds since Jan 1 1970).
This token is sensitive, and we want to write it to dynamoDB, so we need to encrypt it. To do that, we need a KMS key, so we register the KMS client, line 15.
On line 18, we encrypt our plain-text user token using the CMK KMS key, as well as our utf-8 encoded token string.
That interestingly makes it binary, which I haven’t seen before, so we need to encode using base64 to store in dynamo, which we do on line 24.
def handle_auth_code_callback(body, event, auth_code, aad_object_id): | |
# ... | |
token_response = exchange_code_for_token(auth_code, TENANT_ID, CLIENT_ID, CLIENT_SECRET) | |
# Extract the access token and expiration time | |
access_token = token_response["access_token"] | |
expires_in = token_response["expires_in"] | |
# Calculate expiration time in seconds since epoch | |
expires_at = int(time.time()) + expires_in | |
### Encrypt the access token using the CMK key | |
# Initialize the KMS client | |
kms = boto3.client('kms', region_name='us-east-1') # Change region if needed | |
# Encrypt the access token | |
encrypted_token = kms.encrypt( | |
KeyId=cmk_key_alias, | |
Plaintext=access_token.encode("utf-8") | |
) | |
# Base64 encode the encrypted token | |
encrypted_token_base64 = base64.b64encode(encrypted_token['CiphertextBlob']).decode('utf-8') |
What Were We Talking About Again?
Now that we have an enciphered (with KMS) and encoded (with base64) string of a token, we store it in the “token” dynamo table. All items are stored as the aadObjectId string, which we get both from the access code as well as any Teams events, so it’s a very convenient way to map stuff.
We also store the expiresAt int in the table so we can check it later.
We assume here that we ALWAYS reach here after the user has first tried to send a message, then gotten the SSO Card redirect, so we’ve stashed a conversation, so we register a dynamo_db client to go fetch the Conversation they sent initially so we can resume it, line 19.
def handle_auth_code_callback(body, event, auth_code, aad_object_id): | |
# ... | |
encrypted_token_base64 = base64.b64encode(encrypted_token['CiphertextBlob']).decode('utf-8') | |
# Store the access token in DynamoDB, can be used in future transactions for an hour (default expiration) | |
dynamodb_client = boto3.client("dynamodb") | |
dynamodb_client.put_item( | |
TableName=token_table_arn, | |
Item={ | |
"aadObjectId": {"S": aad_object_id}, | |
"accessToken": {"S": encrypted_token_base64}, | |
"expiresAt": {"N": str(expires_at)} | |
} | |
) | |
print("🟢 Successfully stored access token in DynamoDB") | |
# Fetch the conversation body from DynamoDB | |
response = dynamodb_client.get_item( | |
TableName=conversation_table_arn, | |
Key={ | |
"aadObjectId": {"S": aad_object_id} | |
} | |
) |
Wake up Worker Bot
Once we get the conversation event, we hydrate it (line 3).
We’ll want to pass this Conversation payload, as well as the token, to our Worker lambda, so we add the encrypted and encoded token to the payload, line 6.
Now we’re ready to launch the worker, so we register a client (line 9), and trigger the lambda worker, line 12.
If this works properly, the Worker is now responding, so we delete the saved Conversation item from the table for cleanup, line 23.
def handle_auth_code_callback(body, event, auth_code, aad_object_id): | |
# ... | |
conversation_event = json.loads(response.get("Item")["event"]["S"]) | |
# Add the token to the event | |
conversation_event["token"] = encrypted_token_base64 | |
# Trigger the Vera Worker lambda to process the event | |
lambda_client = boto3.client('lambda') | |
# Asynchronously invoke the processor Lambda | |
lambda_client.invoke( | |
FunctionName=os.environ['WORKER_LAMBDA_NAME'], | |
InvocationType='Event', # Async invocation | |
Payload=json.dumps(conversation_event) | |
) | |
# Debug | |
if os.environ.get("VERA_DEBUG") == "True": | |
print("🟢 Successfully invoked the processor Lambda with the access token") | |
# Delete the saved conversation event from DynamoDB | |
dynamodb_client.delete_item( | |
TableName=conversation_table_arn, | |
Key={ | |
"aadObjectId": {"S": aad_object_id} | |
} | |
) |
And that’s it!
Summary
This got WAY LONGER than I planned. There’s a lot of nuance in how Entra IdP works to issue tokens, how lambda and dynamoDB work to store and process data, and in how python can route traffic in this Receiver pattern.
I’m particularly proud of the “automatically open Teams after you authenticate” part, I find that really cool in practice.
Hopefully you have a pretty solid idea of how this pattern works and how to customize it for your user case.
In the next article we’ll go over some of the Worker items for how we look up items and respond to conversations - it’s awesome, and entirely different from Slack.
Until next time. Good luck out there!
kyler