Implementing protected Lambda function URLs in user-facing applications
Table of contents
- 1. The scenario
- 2. Authorization in API Gateway vs function URL
- 2.1. API Gateway (REST type)
- 2.2. Function URL
- 3. Architecture diagram
- 4. Architecture components
- 4.1. Cognito user pool
- 4.2. Cognito identity pool
- 4.3 Amplify
- 4.4. Function URL
- 5. Considerations
- 6. Summary
- 7. Further reading
1. The scenario
The dynamic model selection application currently uses an API Gateway REST API as the entry point. It’s because Bedrock returns streamed output from the selected foundation model, and, as of this writing, only REST APIs support streamed responses back to the client.
But API Gateway can be an overkill in some use cases. What if you just have a small function and don’t want to configure an API Gateway?
Good news, Lambda function URLs also support streamed responses!
2. Authorization in API Gateway vs function URL
Configuring the function URL for streamed responses is simple. It’s only one line of code in CDK (see below).
But how about authorization? How do you ensure that only authorized users can access the endpoint? Wouldn’t it be better to still use an API Gateway?
2.1. API Gateway (REST type)
You can protect your API Gateway endpoints in a few different ways.
The most straightforward option is to use a Cognito user pool. The flow is very simple. The user signs in, then the user pool issues tokens (ID and access tokens), and API Gateway validates them.
If you use a custom token or have custom claims, you can implement a Lambda authorizer function. It decodes the token, runs some verification code and returns an IAM policy that allows or denies the invocation to API Gateway, depending on the token validation outcome.
The third option is to use IAM authorization, which is a great and secure way to control access to an API. It’s a popular pattern to authorize internal, machine-to-machine requests. IAM validates the requests that must be signed with the Signature V4 algorithm.
2.2. Function URL
But let’s say that you want the simplicity of function URLs.
You have two options for your URL. It can be either a public or an IAM protected endpoint.
Public function URLs are - surprise - available for everyone, so unless you’re intentionally making it public, you’ll eventually want to protect the endpoint and authorize user requests.
You can build custom logic in the Lambda function to authorize requests. It’s a workable solution, but it fails to separate responsibilities, i.e., authorization and business logic. Instead, go with IAM authorization.
But here’s the challenge. Your users need valid AWS credentials included inside the SigV4 signature header when your application sends the request, otherwise, IAM denies it. That said, your users need lambda:InvokeFunction and lambda:InvokeFunctionUrl permissions to successfully call your IAM-protected function URL.
Now, your application users around the world, Alices, Bobs, Cecils, etc., are not very likely to be your AWS account users, and you don’t want them to become ones. So how do you give them AWS credentials so that they can invoke the function URL backing your awesome application?
A good practice in this scenario is to use Cognito identity pools.
3. Architecture diagram
This simplified diagram shows the workflow:
For the sake of simplicity, this solution has a Cognito user pool, but you can integrate any supported identity provider into your architecture. More on that below.
4. Architecture components
Let’s review the steps and architecture components.
4.1. Cognito user pool
The user pool contains user authentication data, like username, password, sign-in, and sign-up preferences.
You can also create a user group in the user pool, and add users to it. User groups are actually access control groups since you can assign different IAM roles to different groups. Users belonging to the same groups get the same permissions.
After users successfully authenticate with their username and password (or other sign-in options you configure for them in the user pool), Cognito returns an ID token to the application. The ID token includes the user’s group membership and the name of the corresponding role assigned to the user group.
This is where the user pool’s responsibility ends in this solution. In this example, each user belongs to only one Cognito group. A future post might cover a scenario when users belong to multiple groups.
4.2. Cognito identity pool
The next step and the solution’s critical component is the identity pool.
The ID token issued by the user pool includes the IAM role attached to the group the user is assigned to. The application receives this token from Cognito and calls the identity pool with it (GetId and GetCredentialsForIdentity APIs). After the identity pool verifies the token, it retrieves temporary AWS credentials from STS, and returns them to the application.
The application (in this case, a React app) adds the credentials to the SigV4 signature, signs the request, and attaches the signed headers to the function URL call:
// React component
import { SignatureV4 } from '@aws-sdk/signature-v4';
import { Sha256 } from '@aws-crypto/sha256-js';
import { HttpRequest } from '@smithy/protocol-http';
// ... more component code
const signer = new SignatureV4({
credentials, // easy to get them with Amplify
region: REGION,
service: 'lambda',
sha256: Sha256,
});
const signedRequest = await signer.sign(request);
const res = await fetch(LAMBDA_FUNCTION_URL, {
method: 'POST',
headers: signedRequest.headers,
body,
});
// ... more code
4.3. Amplify
You can use the Amplify React package to simplify workflows, such as sign-in logic or retrieving AWS credentials from the session object.
The aws-amplify/auth package includes the fetchAuthSession method, where the AWS credentials can be obtained with just two lines of code:
const session = await fetchAuthSession();
const credentials = session.credentials;
The full code is available on my GitHub page in this repo.
4.4. Function URL
You’ll need to configure authType to AWS_IAM for IAM authorization on the function URL.
Also, since the response is streamed, I configured the URL accordingly.
Third, since the function URL is invoked from a browser via a React application, I also configured the CORS settings.
modelAbstractionFnForUrl is a Lambda function construct, so you can enable the URL with the addFunctionUrl method:
const modelAbstractionFnUrl = modelAbstractionFnForUrl.addFunctionUrl({
// configure IAM authorization
authType: lambda.FunctionUrlAuthType.AWS_IAM,
// enable browsers to call the function URL
// narrow it down to the actual domain in production
cors: {
allowedOrigins: ['*'],
allowedMethods: [lambda.HttpMethod.ALL],
allowedHeaders: ['*'],
maxAge: cdk.Duration.seconds(300),
},
// configure STREAMED response
invokeMode: lambda.InvokeMode.RESPONSE_STREAM,
});
You now have a protected function URL that returns streamed responses! After your users successfully log in to the application, they can send prompts to the foundation model via the URL.
The entire code (infra and application) can be found in the GitHub repo.
5. Considerations
As stated above, the use case largely influences whether function URLs are a feasible solution. If you need the features of API Gateway, you don’t necessarily need an identity pool. But you can design a similar architecture for API Gateway. In that case, you’ll need to change the IAM role’s permissions attached to the user group and the SigV4 code from lambda to execute-api.
As always, the solution presented in this post is not production-ready, and its feasibility should always be assessed against your use cases.
6. Summary
Lambda function URLs support streaming responses and can be securely restricted to authenticated users via Cognito user pools and identity pools.
The client signs requests using SigV4, which includes the temporary AWS credentials, based on IAM roles attached to Cognito user pool groups.
7. Further reading
Controlling access to resources with Cognito groups and IAM roles - Authenticated users in Cognito Identity pools, Part 1 - Something similar with DynamoDB, and a bit more details on how the identity pool behaves