Coder Social home page Coder Social logo

sfn-callback-urls's Introduction

sfn-callback-urls

AWS Step Functions lets you create serverless workflows in the form of state machines. Step Function supports long-running tasks, where Step Function gives out a token, which you later send back along with the result of the task. The sfn-callback-urls application is designed to make it easier to use these tokens in callback-based situations, like sending an email with a link to click, or passing a callback URL to another service.

How does it work?

You can create a long-running task in Step Functions in two ways. One is activities, which require a polling worker. The other is callback tasks, in which the token is sent out in an event (to a Lambda function, SQS queue, etc.).

Once you have the token, you call the sfn-callback-urls with the token and a set of potential outcomes. You receive a URL for each outcome, which can then be passed on to something deciding the outcome. The chosen outcome URL can then be POST'd or GET'd, and will send that outcome on to Step Functions, completing the task.

Test it out

# *** Do this part if you are deploying from SAR ***

# Visit https://serverlessrepo.aws.amazon.com/applications/arn:aws:serverlessrepo:us-east-2:866918431004:applications~sfn-callback-urls
# and deploy the application. Note the stack name you used, and set it below.

STACK_NAME=TODO_DEPLOYED_APP_STACK_NAME

# *** Do this part if you are deploying from source ***

STACK_NAME=SfnCallbackUrls

sam build --use-container && sam deploy --guided --stack-name $STACK_NAME

# *** Now, let's get to it ***

# Set these values
NAME=TODO_YOUR_NAME
EMAIL=TODO_YOUR_EMAIL

# This gets the Lambda function we call for creating callback URLs
FUNC=$(aws cloudformation describe-stacks --stack-name $STACK_NAME --query "Stacks[0].Outputs[?OutputKey=='Function'].OutputValue" --output text)

# Deploy the example stack
aws cloudformation deploy --template-file example/template.yaml --stack-name SfnCallbackUrlsExample --parameter-overrides Email=$EMAIL CreateUrlsFunction=$FUNC --capabilities CAPABILITY_IAM

# Go to your email and confirm the SNS subscription

STATE_MACHINE=$(aws cloudformation describe-stacks --stack-name SfnCallbackUrlsExample --query "Stacks[0].Outputs[?OutputKey=='StateMachine'].OutputValue" --output text)

# Run the example state machine
aws stepfunctions start-execution --state-machine-arn $STATE_MACHINE --input "{\"name\": \"$NAME\"}"

# Now you will get an approve/reject email, followed by a confirmation of the same

Security

sfn-callback-urls is entirely stateless; your token is not stored in it anywhere. Instead, the token is encoded into the callback URL payload, along with the output. This means that if you lose the URLs and have not stored the tokens somewhere else, you cannot cause the corresponding state machine task to complete or fail. However, this does not mean you should store the tokens elsewhere. Instead, you may be able to provide a sensible timeout on your task, or simply stop the execution entirely and start a new one. Try these approaches before squirreling away tokens.

By default, sfn-callback-urls creates a KMS key to encrypt callback payloads, so that having a callback URL neither allows you to inspect the payload nor modify it before using it. The default costs money!. There are two alternatives. If you have your own KMS key, you can put the key ARN in the EncryptionKeyArn stack parameter, and it will use that instead of creating one.

If you want to disable encryption entirely, you can set the DisableEncryption stack parameter to true. The consequence of disabling encryption is that the contents of a callback URL, including the token and the output you want to send to the state machine, are inspectable. Additionally, somebody who has gotten a token they should not have could construct a callback URL use it, and since the callback are unauthenticated this would constitute a privilege escalation. However, it still requires that the token have previously leaked, and that the meaning of the token (the state machine it corresponds to, etc.) is known.

The action name and type are put as query parameters on the callback URLs for convenience, to make the URLs more easily distinguishable, but they are not trusted. The name and type are also stored in the payload, and when the callback is processed, the two are compared and the callback is rejected if they don't match.

The callback method is unauthenticated, it will always result in a Lambda invocation. With encryption enabled, no valid input should be able to be provided that hasn't already gone through the authentication URL creation call. However, it is still susceptible to denial-of-wallet attacks from someone who knows the endpoint (such as by having seen a callback URL). If this is a concern, a good course of action is to enable AWS WAF on the API Gateway.

Creating URLs

There are two ways to invoke the service: through the API, or direct to a Lambda. Both take identical input payloads. The API base url can be found as the Api output of the CloudFormation stack. To create callback URLs with the API, POST the JSON payload (with theContent-Type header set to application/json) to the /urls path. Or, simply invoke the Lambda function found as the Function output of the stack. Permissions for both of these are given by the IAM managed policy found as the Policy output of the CloudFormation stack.

Input

{
    "token": "<the token from Step Functions>", // required
    "actions": [ // you must provide at least one action
        {
            "name": "<a name for this action>",
            "type": "success", // this action will cause SendTaskSuccess
            "output": { // required, can be any JSON type
                "<your>": "<content>"
            },
            "response": {} // optional, see below
        },
        { // can have as many actions of the same type as you want
            "name": "<name2>",
            "type": "success",
            "output": "<a different output>"
        },
        {
            "name": "<name3>",
            "type": "failure",  // this action will cause SendTaskFailure
            "error": "<your error code>", // optional
            "cause": "<your error cause>" // optional
        },
        {
            "name": "<name4>",
            "type": "heartbeat" // this action will cause SendTaskHeartbeat (can invoke this type of callback more than once)
        }
    ],
    "expiration": "<ISO8601-formatted expiration>", // optional
    "enable_output_parameters": true // optional, and must be enabled on the stack, see below
}

Actions

For each action you define, you will get a callback URL. Each action you provide has a name and a type. The name is your label for the action. The type corresponds to what Step Functions API the callback will cause to be invoked, and must be one of success, failure, or heartbeat, corresponding to the SendTaskSuccess, SendTaskFailure, and SendTaskHeartbeat API calls, respectively.

For success actions, you must provide an output field, whose value will be passed to the same field in SendTaskSuccess.

For failure options, you may optionally provide error and cause fields whose values are strings, which will be passed to the same fields in SendTaskFailure.

Callback response specification

In every action, you can provide a response specification in the response field with an object like this:

{
    "redirect": "https://example.com", // if the callback is successful, redirect the user to the given URL
}

or this:

{
    "json": {"hello": "world"}, // choose the JSON object returned by the callback for the application/json content-type
    "html": "<html>hello, world</html>", // choose the body returned by the callback for the text/html content-type
    "text": "hello, world" // choose the body returned by the callback for the text/plain content-type
}

All fields are optional, and are only used when the callback is successfully processed; all errors return fixed content. redirect takes precedence over the other fields.

Expiration

You can optionally provide an expiration value as an ISO8601-formatted datetime; if a callback is made after then, it will be rejected.

Parameterizing callbacks

If you've got a lot of different potential successful outputs, you may find it easier to parameterize your callbacks. This feature is disabled by default due to the security considerations described below; you have to set the EnableOutputParameters stack parameter to true. Then, you must also opt-in when creating URLs by setting the enable_output_parameters field to true in your request. Any URLs created without enable_output_parameters set to true will not use parameterized output when the callbacks are processed. If EnableOutputParameters is changed back to false, any previously-created callbacks with parameters enabled will be now rejected.

Once set, any strings in the output field for a success action, the error and cause fields for a failure action, and all the strings in the response object are passed through the Python string.Template.substitute function, using all the query parameters except for the payload. In addition to the action and name query parameters that are already there, you can use your own field name in your strings, and append values to the callback URL query string. You can therefore create many outputs from one callback URL returned by the service. Note that failure to provide all of the necessary parameters will cause the callback to be rejected.

Parameterized callback security

Note that these extra query parameters are inherently unvalidated by the service, and therefore when enabled, someone could modify the query parameters to send unexpected output.

Output

On success:

{
    "transaction_id": "<a unique id>", // for correlation
    "urls": {
        "<action name>": "<url>"
    },
    "expiration": "<ISO8601-formatted datetime>" // only if you provided an expiration
}

On error:

{
    "error": "<error code>",
    "message": "<error description>"
}

Invoking the callback

You can either GET or POST the callback. The response respects the Accept header, supporting application/json, text/html, and text/plain, defaulting to JSON otherwise. As outlined above, the response can be customized when the callbacks are created.

The JSON response on success:

{
    "transaction_id": "<the same id returned by the create call>",
    "action": {
        "name": "<the action name>",
        "type": "<the action type>"
    }
}

The JSON response on error:

{
    "error": "<error code>",
    "message": "<error description>"
}

POST actions

If you'd like to use the body of a POST callback to send output for your task, for example with a webhook, you can do this with post actions. A post action has a non-empty list of outcomes, which use the same form as actions with a few extra fields. Each outcome has a name and a type, where the type is one of success, failure, or heartbeat.

Each outcome has a schema, which must be a JSON Schema that will be evaluated against the POST body. The first outcome whose schema validates against the body will used. If no schema matches the POST body, the callback results in an error.

Like in an action, a success outcome can include an output field, and a failure outcome can have error and cause; these are fixed values. To use the entire body of the request as the output for a success outcome, use "output_body": true in your outcome. To select information from the request body, you can use output_path to specify a JSONPath. Because JSONPath expressions can return multiple values, the output will always be an array; if you expect your expression to return a single object, you must select it from the array in your state machine. Similarly, you can use error_path and cause_path; if these return paths return a single string, it will be used, otherwise the resulting JSON array of matches will be stringified.

Outcomes can contain responses. POST actions disable output parameters, even if the create URLs call requests that they are enabled (other actions in such a call will have them enabled).

A sample request to create a POST action URL looks the following:

{
    "token": "<the token from Step Functions>", // required
    "actions": [ // you must provide at least one action
        {
            "name": "<a name for this action>",
            "type": "post",
            "outcomes": [
                {
                    "name": "<a name for this happy outcome>",
                    "type": "success",
                    "schema": { // require an object that looks like {"result": "good"}
                        "type": "object",
                        "properties": {
                            "result": {
                                "const": "good"
                            }
                        },
                        "required": [ "result" ]
                    },
                    "output_body": true
                },
                {
                    "name": "<a name for this sad outcome",
                    "type": "failure",
                    "schema": { // require an object that looks like {"result": "bad", "reason": "..."}
                        "type": "object",
                        "properties": {
                            "result": {
                                "const": "bad"
                            },
                            "reason": {
                                "type": "string"
                            }
                        },
                        "required": [ "result", "reason" ]
                    },
                    "error_path": "$.reason"
                }
            ]
        },
        { // can have other actions in addition to POST actions
            "name": "<name2>",
            "type": "success",
            "output": "<a different output>"
        }
    ]
}

This feature is disabled by default due to the security considerations described below; you have to set the EnablePostActions stack parameter to true. If EnablePostActions is changed back to false, any previously-created POST action callbacks will be now rejected.

POST action security

POST actions allow arbitrary output to be passed into an unauthenticated endpoint, and are therefore disabled by default. Users are required to provide a JSON schema to validate the body, but this can be the empty schema.

sfn-callback-urls's People

Contributors

benkehoe avatar

Stargazers

 avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar

Watchers

 avatar  avatar  avatar

sfn-callback-urls's Issues

Add parameter to enable tracing

Hi,

thats an awesome project thank you ๐Ÿ‘

It would be super nice to be able to enable the xray tracing feature.
Currently we can follow the trace through our whole step function until the request to those lambdas.

what can be enhanced:

  • enable tracing in lambdas
  • wrap clients with xray where makes sense
  • allow to set the tracing id in ProcessCallbackFunction from input

The last point is useful if you want to follow the traces even after the step function receives the callback.

errorMessage': "module 'aws_encryption_sdk' has no attribute 'KMSMasterKeyProvider'"

Hi

I followed the blog post https://aws.amazon.com/blogs/aws/using-callback-urls-for-approval-emails-with-aws-step-functions/ and had the encryption enabled, however it the functionality fails as it throws an error

{'errorMessage': "module 'aws_encryption_sdk' has no attribute 'KMSMasterKeyProvider'", 'errorType': 'AttributeError', 'stackTrace': ['  File "/var/lang/lib/python3.8/imp.py", line 234, in load_module\n    return load_source(name, filename, file)\n', '  File "/var/lang/lib/python3.8/imp.py", line 171, in load_source\n    module = _load(spec)\n', '  File "<frozen importlib._bootstrap>", line 702, in _load\n', '  File "<frozen importlib._bootstrap>", line 671, in _load_unlocked\n', '  File "<frozen importlib._bootstrap_external>", line 783, in exec_module\n', '  File "<frozen importlib._bootstrap>", line 219, in _call_with_frames_removed\n', '  File "/var/task/create_urls.py", line 50, in <module>\n    MASTER_KEY_PROVIDER = aws_encryption_sdk.KMSMasterKeyProvider(\n']}

I am trying to look and check the createUrls code but unable to do so. Can you please help me in resolving this, it's an urgent requirement.

Appreciate any help I can get @benkehoe @bbernays. Thank you.

Handle custom domains

If the user wants a friendly or trusted domain in the callback URLs, they could add that to the API Gateway, but the callback URL generation process needs to understand it.

Allow POST bodies to contain (unauthenticated) task output

You may want to create a callback URL to pass to some other service, with the expectation that it will POST back the response in JSON format. The body of the request would then be used as the task output.

Potentially make expiration required for this option.

Tentative action format:

{
  "type": "post",
  "outcomes": [
    {
      "type": "success",
      "schema": {},
      "output": "<JSONPath>"
    },
    {
      "type": "failure",
      "schema": {},
      "error": "<JSONPath>"
    }
  ]
}

The schemas would be evaluated in order, and the first matching one would be used; if no matches are found the callback would error out.

Add architecture diagram to project description

Hi,

awesome project as a shortcut to SFN callback handlers. Thanks a lot.

May I ask you to add an architecture diagram to the README. This will help people a lot in understanding the involved AWS services. (e.g. it took me some time to understand, that the Callback Lambda is called by an API Gateway).

Best
Michael

KMSMasterKeyProvider Breaking Change in aws_encryption_sdk

Hi

Version 2.0 of the aws_encryption_sdk has removed support for aws_encryption_sdk.KMSMasterKeyProvider. From the link below it looks like you can just replace it with aws_encryption_sdk.StrictAwsKmsMasterKeyProvider. I am about to test this so will update this issue after that.

https://aws-encryption-sdk-python.readthedocs.io/en/latest/index.html?highlight=KMSMasterKeyProvider#breaking-changes

Edit: aws_encryption_sdk.decrypt & aws_encryption_sdk.encrypt also need changed as per that link.

Thanks!

Optionally add template outputs to Parameter Store

This app can be deployed once to an account and used by multiple state machines. Any code that uses the app shouldn't have to hardcode the Lambda function name or API Gateway URL. So there should be a way to store those values to let that code look up the values for a centrally-deployed instance of this app.

This must be opt-in. If someone is using the SAR app embedded in their own CloudFormation template, the app should stay "local" to that stack, as there may be many instances of the app deployed and they should not all attempt to write their outputs to that central place.

Parameter Store is a good option for this; the lookup is then possible for code deployed directly or using CloudFormation. It would be nice to use CloudFormation exports as well, but these cannot be made conditional at this time.

Response rejected, Invalid Payload

I am trying to use this application to test one of the solution and when I was testing this using the steps on this article hosted at jtekdata.

After the final step, when I tried to execute the Step function, I got the following error after clicking on either of Approve or Reject URL.

Approve URL click response

Response rejected!
Details:
{
  "error": "InvalidPayload",
  "message": "Base64 error (Incorrect padding)"
}

Reject URL click response

Response rejected!
Details:
{
  "error": "InvalidPayload",
  "message": "Decryption error (SerializationError:No signature found in message)"
}

I followed all the steps in exact order as mentioned in the article. I was trying to understand how to resolve this issue. Any help would be appreciated.

Debugging options tried

  • I tried to get the URL generated in the CloudWatch logs (Got urls:{} section) and apparently that URL works like a charm. So seems like there is an issue when that URLs are sent out via SNS topic.

TIA!

Post action body must be JSON

Post to callback url does not succeed due to InvalidPostActionBody. However the posted body is valid JSON and the Content-Type header is set to application/json;charset=utf-8.

It seems like the problem is a strict match of the Content-Type to application/json, which fails when the charset is specified in the Content-Type header.

Could this comparison be the problem?

if get_header(request, 'content-type') == 'application/json':

Example Post and log message:

Post to Callback:
Content-Type: application/json;charset=utf-8

{
    "source": "foo",
    "id": "bar",
    "state": "success",
}

Log of ProcessCallbackFunction:

{
    "timestamp": "2020-12-11T09:56:14.576580",
    "decode_time": 0.29622951199996805,
    "transaction_id": "xxx",
    "action": {
        "name": "post",
        "type": "post"
    },
    "error": {
        "type": "RequestError",
        "error": "InvalidPostActionBody",
        "message": "Post action body must be JSON"
    }
}

Recommend Projects

  • React photo React

    A declarative, efficient, and flexible JavaScript library for building user interfaces.

  • Vue.js photo Vue.js

    ๐Ÿ–– Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.

  • Typescript photo Typescript

    TypeScript is a superset of JavaScript that compiles to clean JavaScript output.

  • TensorFlow photo TensorFlow

    An Open Source Machine Learning Framework for Everyone

  • Django photo Django

    The Web framework for perfectionists with deadlines.

  • D3 photo D3

    Bring data to life with SVG, Canvas and HTML. ๐Ÿ“Š๐Ÿ“ˆ๐ŸŽ‰

Recommend Topics

  • javascript

    JavaScript (JS) is a lightweight interpreted programming language with first-class functions.

  • web

    Some thing interesting about web. New door for the world.

  • server

    A server is a program made to process requests and deliver data to clients.

  • Machine learning

    Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.

  • Game

    Some thing interesting about game, make everyone happy.

Recommend Org

  • Facebook photo Facebook

    We are working to build community through open source technology. NB: members must have two-factor auth.

  • Microsoft photo Microsoft

    Open source projects and samples from Microsoft.

  • Google photo Google

    Google โค๏ธ Open Source for everyone.

  • D3 photo D3

    Data-Driven Documents codes.