DEV Community

Cover image for MCP OAuth on AWS Lambda with WorkOS

MCP OAuth on AWS Lambda with WorkOS

Photo by Bruno Alves on Unsplash

Goal

I plan to implement an MCP server secured with OAuth. I will use a third-party identity provider (WorkOS for this blog post, but general rules would be the same for other services). The created resource is meant to be used by users in their MCP clients.

The main idea is to build a serverless solution that might integrate with the existing authentication flow. From the user's perspective, using MCP Server should feel similar to using other services.

Problem

The current MCP specification assumes that the MCP Server will handle authorization as well. It might work if you have a single MCP server, but it doesn't fit well into an enterprise landscape, with dozens of services and a separate layer for identity provider.

Architecture

Image description

I will create two separate services behind the API Gateway. One is responsible for the authorization bridge with WorkOS, the other for the actual MCP server logic.

I use a stateless version of the MCP server, so both services will run on Lambda Functions.

API Gateway will be responsible for authorizing the calls to the resource server (using Lambda authorizer). With this approach, my MCP resource server won't know anything about authorization logic.

Implementation

The whole code is available in this repo

Initial setup

To be honest, I don't fully understand the MCP specification, especially when it comes to authorization. Moreover, MCP clients do not rush to implement the updated specification, which makes things even more tricky.

I will test my server with goose ( open source AI client ) using the mcp-remote package for handling a Streamable HTTP type of MCP server.

To begin, I create a simple mock authorizer that returns Unauthorized by default.

// authorizer/src/main.rs
use std::collections::HashMap;

use lambda_runtime::{run, service_fn, tracing, Error, LambdaEvent};
use serde::{Deserialize, Serialize};
mod generic_handler;

// https://docs.aws.amazon.com/apigateway/latest/developerguide/http-api-lambda-authorizer.html

#[derive(Deserialize)]
pub struct Request {
    pub headers: HashMap<String, String>
}

#[derive(Serialize)]
pub struct Response {
    pub isAuthorized: bool,
    pub context: HashMap<String, String>
}


#[tokio::main]
async fn main() -> Result<(), Error> {
    tracing::init_default_subscriber();

    run(service_fn(function_handler)).await
}

async fn function_handler(event: LambdaEvent<Request>) -> Result<Response, Error> {
    // auth logic 
    Ok(Response {
        isAuthorized: false,
        context: HashMap::new()
    })
}
Enter fullscreen mode Exit fullscreen mode

My initial MCP resource server returns a dummy message in response to POST request on the /mcp route

// mcp-resource/src/main.rs
use axum::{http::StatusCode, response::IntoResponse, routing::post, Router};
use tower_http::trace::{DefaultMakeSpan, TraceLayer};
use tracing_subscriber::EnvFilter;

const PORT: u16 = 8080;

#[tokio::main]
async fn main() {

    tracing_subscriber::fmt()
        .with_env_filter(
            EnvFilter::try_from_default_env()
                .unwrap_or_else(
                    |_| EnvFilter::new("debug")
                )
        )
        .init();

    let app = Router::<()>::new()
        .route("/mcp", post(mcp_handler))
        .layer(
            TraceLayer::new_for_http()
                .make_span_with(DefaultMakeSpan::new()
                    .include_headers(true)
                )
        );

    let listener = tokio::net::TcpListener::bind(format!("0.0.0.0:{}", PORT)).await.unwrap();

    axum::serve(listener, app).await.unwrap();
}

pub async fn mcp_handler() -> impl IntoResponse {
    "hello"
}
Enter fullscreen mode Exit fullscreen mode

Finally, the infrastructure defines a HTTP API Gateway and both functions. My MCP resource server runs the axum application behind Lambda Web Adapter

infrastructure/lib/infrastructure-stack.ts
import * as cdk from 'aws-cdk-lib';
import { Construct } from 'constructs';
import { RustFunction } from '@cdklabs/aws-lambda-rust'

export class MCPAuthLambda extends cdk.Stack {
  constructor(scope: Construct, id: string, props?: cdk.StackProps) {
    super(scope, id, props);

    const authorizer = new RustFunction(this, 'Authorizer', {
      entry: '../authorizer/Cargo.toml',
      environment: {
        RUST_LOG: 'info'
      }
    })

    const adapterLayer = cdk.aws_lambda.LayerVersion.fromLayerVersionArn(
      this,
      'WebAdapterLayer',
      'arn:aws:lambda:us-east-1:753240598075:layer:LambdaAdapterLayerX86:25'
    );

    const mcpResourceServer = new RustFunction(this, 'McpResourceServer', {
      entry: '../mcp-resource',
      layers: [ adapterLayer ],
      environment: {
        RUST_LOG: 'info'
      }
    })

    const httpApi = new cdk.aws_apigatewayv2.HttpApi(this, 'MCP-Auth-API');

    httpApi.addRoutes({
      path: '/mcp',
      authorizer: new cdk.aws_apigatewayv2_authorizers.HttpLambdaAuthorizer(
        'Authorizer', authorizer, {
        responseTypes: [cdk.aws_apigatewayv2_authorizers.HttpLambdaResponseType.SIMPLE]
      }),
      methods: [cdk.aws_apigatewayv2.HttpMethod.GET, cdk.aws_apigatewayv2.HttpMethod.POST],
      integration: new cdk.aws_apigatewayv2_integrations.HttpLambdaIntegration(
        "mcp-resource",
        mcpResourceServer)
    });

    new cdk.CfnOutput(
      this,
      'ApiUrl',
      {
        value: httpApi.url!
      }
    )

  }
}
Enter fullscreen mode Exit fullscreen mode

Local testing

I use SAM local to run APIs locally and test. To be able to do so with AWS CDK, I run cdk synth in the infrastructure directory, and then sam local start-api -t MCPAuthLambda.template.json in the cdk.out directory.

Now I will add my MCP to the goose by starting

goose configure
Enter fullscreen mode Exit fullscreen mode

and adding mcp-remote extension:

Image description

Now, when I start goose, it tries to use my MCP server:

Image description

Ok, as expected, the goose tries to connect to the /mcp route, and receives the 401 response. Then it tries to get /.well-known/oauth-authorization-server and fails.

The next step would be to start implementing our authorization logic.

Authorization Server

/.well-known/oauth-authorization-server

According to the specification, the first step in the auth process is metadata discovery. In other words, the MCP client queries for the information about expected endpoints, and falls back to the default ones if the .well-known endpoint is not available.

Let's start from the well-known endpoint

// mcp-authorize/src/main.rs
use axum::{debug_handler, response::IntoResponse, routing::get, Json, Router};

const PORT: u16 = 8081;

#[tokio::main]
async fn main() {

    let app = Router::<()>::new().route(
        "/.well-known/oauth-authorization-server",
        get(well_known_handler),
    );

    let listener = tokio::net::TcpListener::bind(format!("0.0.0.0:{}", PORT))
        .await
        .unwrap();


    axum::serve(listener, app).await.unwrap();
}

#[debug_handler]
async fn well_known_handler() -> impl IntoResponse {

        Json(WellKnownAnswer {
            authorization_endpoint: "http://localhost:3000/authorize".to_string(),
            registration_endpoint: "http://localhost:3000/register".to_string(),
            grant_types_supported: vec!["authorization_code".to_string(), "refresh_token".to_string()],
            scopes_supported: vec!["email".to_string(), "offline_access".to_string(), "openid".to_string(), "profile".to_string()],
            response_modes_supported: vec!["query".to_string()],
            response_types_supported: vec!["code".to_string()],
            token_endpoint: "http://localhost:3000/token".to_string(),
            issuer: "https://fresh-lamb-82-staging.authkit.app".to_string(),
        })

}


#[derive(serde::Serialize)]
pub struct WellKnownAnswer {
    pub authorization_endpoint: String,
    pub registration_endpoint: String,
    pub grant_types_supported: Vec<String>,
    pub scopes_supported: Vec<String>,
    pub response_modes_supported: Vec<String>,
    pub response_types_supported: Vec<String>,
    pub token_endpoint: String,
    pub issuer: String,
}
Enter fullscreen mode Exit fullscreen mode

I also updated the infrastructure with the newly created service

// ...
const mcpAuthorizationServer = new RustFunction(this, 'McpAuthorizationServer', {
      entry: '../mcp-authorize',
      layers: [ adapterLayer ],
      environment: {
        RUST_LOG: 'debug',
        PORT: '8081',
      }
    })
//...
httpApi.addRoutes({
      path: '/.well-known/oauth-authorization-server',
      methods: [cdk.aws_apigatewayv2.HttpMethod.GET],
      integration: new cdk.aws_apigatewayv2_integrations.HttpLambdaIntegration(
        "mcp-authorization",
        mcpAuthorizationServer)
    });
// ...
Enter fullscreen mode Exit fullscreen mode

Rebuild the project with cdk synth and run sam local

Now, when starting goose, I can see that we are one step further

Image description

/register

MCP spec expects that the OAuth flow allows dynamic client registration. In my case, I don't plan to register the new client_id each time, and I will use /register endpoint to plug the configured WorkOS server.

I take the client_id from the main page of the WorkOS dashboard, https://dashboard.workos.com/get-started

According to the RFC 7591, the client_id is the only required field in the response.
It turned out that mcp-remote also requires the redirect_uris property in the response. I will take it from the payload of the request sent to the /register endpoint

// mcp-authorize/src/main.rs
// ...

    let app = Router::<()>::new().route(
        "/.well-known/oauth-authorization-server",
        get(well_known_handler),
    )
    .route("/register", post(registration_handler));
// ...
#[debug_handler]
async fn registration_handler(Json(req): Json<ClientRegistrationRequest>) -> impl IntoResponse {
    println!("{:?}", req);
    Json(ClientRegistrationAnswer{
        client_id: CLIENT_ID.to_string(),
        redirect_uris: req.redirect_uris,
        response_types: req.response_types,
        grant_types: req.grant_types,
        client_name: req.client_name
    })
}

#[derive(Debug, Deserialize)]
pub struct ClientRegistrationRequest {
    pub client_name: String,
    pub redirect_uris: Vec<String>,
    pub grant_types: Vec<String>,
    pub token_endpoint_auth_method: String,
    pub response_types: Vec<String>,
}

#[derive(Serialize)]
pub struct ClientRegistrationAnswer {
    client_id: String,
    redirect_uris: Vec<String>,
    response_types: Vec<String>,
    grant_types: Vec<String>,
    client_name: String
}
//...
Enter fullscreen mode Exit fullscreen mode

I add the endpoint to APIs

// ...
    httpApi.addRoutes({
      path: '/register',
      methods: [cdk.aws_apigatewayv2.HttpMethod.POST],
      integration: new cdk.aws_apigatewayv2_integrations.HttpLambdaIntegration(
        "mcp-authorization",
        mcpAuthorizationServer)
    });
Enter fullscreen mode Exit fullscreen mode

And start goose one more time

Now the default browser is opened with the call for the /authorize endpoint

Image description

/authorize

When performing authorization, my goal is to return redirect to the user with the proper parameters, so WorkOS will perform heavy lifting.
I built the logic based on the documentation.
The only tricky part is that I can't simply use the redirect_uri sent by the MCP client, as I have not added it to the list of allowed URIs in the WorkOS Dashboard. It happens because we are not registering the client dynamically, so our underlying authorization server doesn't know about the specific redirect_uri sent with the request.
I solve it by storing the original redirect uri as a state, and using the URI registered on my server:

// ...
#[debug_handler]
async fn authorization_handler(Query(params): Query<HashMap<String, String>>) -> impl IntoResponse {

    let original_uri = params.get("redirect_uri").unwrap();

    let state = BASE64_STANDARD.encode(original_uri);

    let local_redirect = "http://localhost:3000/callback";

    let url = reqwest::Url::parse_with_params(
        "https://api.workos.com/user_management/authorize",
        &[
            ("response_type", "code"),
            ("client_id", CLIENT_ID),
            ("redirect_uri", local_redirect),
            ("code_challenge", params.get("code_challenge").unwrap()),
            ("code_challenge_method", "S256"),
            ("provider", "authkit"),
            ("state", state.as_str()),
            ("scope", "openid profile email offline_access"),
        ],
    )
    .unwrap();

    Redirect::temporary(url.as_str())

}
// ...
Enter fullscreen mode Exit fullscreen mode

BTW - when prototyping, I use some hardcoded variables, and I will clean them up later, when deploying to AWS.

Now I start goose again and see a login page open in the default browser:

Image description

It is super convenient to have the whole UI implemented by WorkOS AuthKit.

/callback

This endpoint is not part of the specification, but it is needed for statically created clients with registered redirect_uris. I will get the response from the authorize action and redirect it to the original redirect_uri stored in the request state

// ...
#[debug_handler]
async fn callback_handler(Query(params): Query<HashMap<String, String>>) -> impl IntoResponse {
    let code = params.get("code").unwrap();

    let state = params.get("state").unwrap();

    let original_redirect_uri = String::from_utf8(BASE64_STANDARD.decode(state).unwrap()).unwrap();

    let response_url = reqwest::Url::parse_with_params(
        original_redirect_uri.as_str(),
        &[("code", &code), ("state", &state)],
    )
    .unwrap();

    Redirect::temporary(response_url.as_str())
}
// ...
Enter fullscreen mode Exit fullscreen mode

Now, after logging in, I can see that the code was properly redirected

Image description

/token

The final part of the authorization process is to exchange the code for the token.

Our flow allows two types of flows authorization_code and redresh_token. Token handler checks the type of the request, and goes to the WorkOS to get the actual token.

async fn token_handler(State(state): State<AppState>, Form(form): Form<HashMap<String, String>>) -> impl IntoResponse {

    let grant_type = form.get("grant_type").unwrap();

    let client = reqwest::Client::new();

    let request = match grant_type.as_str() {
        "authorization_code" => {
            let code = form.get("code").unwrap();
            let code_verifier = form.get("code_verifier").unwrap();
            let client_id = form.get("client_id").unwrap();

            json!({
                "client_id": client_id,
                "client_secret": state.workos_client_secret,
                "grant_type": "authorization_code",
                "code": code,
                "code_verifier": code_verifier
            })
        }
        "refresh_token" => {
            let refresh_token = form.get("refresh_token").unwrap();
            let client_id = form.get("client_id").unwrap();

            json!({
                "client_id": client_id,
                "client_secret": state.workos_client_secret,
                "grant_type": "refresh_token",
                "refresh_token": refresh_token,
            })
        }
        _ => panic!("Invalid grant type"),
    };

    println!("token request: {}", request);

    let res = client
        .post("https://api.workos.com/user_management/authenticate")
        .body(serde_json::to_string(&request).unwrap())
        .header("Content-Type", "application/json")
        .send()
        .await
        .unwrap();

    let authkit_response: AuthkitAuthResult =
        serde_json::from_str(res.text().await.unwrap().as_str()).unwrap();

    println!("token response {:?}", &authkit_response);

    let expires_at = chrono::Utc::now().timestamp() + 3600;

    let token_result = TokenResponse {
        access_token: authkit_response.access_token,
        refresh_token: authkit_response.refresh_token,
        token_type: "Bearer".to_string(),
        expires_at,
    };

    Json(token_result)
}
//...
#[derive(Serialize, Deserialize, Debug)]
pub struct TokenResponse {
    access_token: String,
    refresh_token: String,
    token_type: String,
    expires_at: i64,
}

#[derive(Serialize, Deserialize, Debug)]
pub struct AuthkitAuthResult {
    access_token: String,
    refresh_token: String,
}
// ...
Enter fullscreen mode Exit fullscreen mode

Quick check and it looks that the authorization flow is successfully finished, as now I can see the error from the initial /mcp endpoint - I need to implement API Gateway authorizer

Authorizer

I've planned to set up everything and test locally before even deploying to AWS. It turned out that it is not possible to fully test the lambda authorizer with sam local start-api command.

Lambda authorizer is quite straightforward. I am getting jwks and decode JWT token.

// authorizer/src/token.rs

use jsonwebtoken::{decode_header, DecodingKey, TokenData};
use serde::{Deserialize, Serialize};

#[derive(Debug, Serialize, Deserialize)]
pub struct Claims {
    pub sub: String,
    pub exp: usize,
}

#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct JWK {
    pub kid: String,
    pub kty: String,
    pub alg: String,
    pub n: String,
    pub e: String,
}

#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct JWKS {
    pub keys: Vec<JWK>,
}

pub async fn get_jwks(jwks_url: String) -> JWKS {
    let jwks_resp = reqwest::get(jwks_url).await.unwrap();

    let jwks: JWKS = jwks_resp.json().await.unwrap();

    jwks

}

pub async fn check_token(token: &str, keys: &JWKS) -> Result<TokenData<Claims>, String> {

    let header = decode_header(token).unwrap();

    let kid = header.kid.ok_or("No kid found in token header")?;

    let jwk = keys.keys.iter()
        .find(|k| k.kid == kid)
        .ok_or("No matching kid found in jwks")?;

    let decoding_key = DecodingKey::from_rsa_components(&jwk.n, &jwk.e)
        .map_err(|op| format!("Error: {:?}", op))?;

    let mut validation = jsonwebtoken::Validation::new(jsonwebtoken::Algorithm::RS256);

    validation.validate_exp;

    let token_data = jsonwebtoken::decode::<Claims>(token, &decoding_key, &validation)
    .map_err(|op| format!("Error: {:?}", op))?;

    println!("{:?}", token_data.claims);

    Ok(token_data)
}
Enter fullscreen mode Exit fullscreen mode

And the handler

// authorizer/src/main.rs

use std::collections::HashMap;

use lambda_runtime::{run, service_fn, tracing, Error, LambdaEvent};
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use token::JWKS;

mod token;

// https://docs.aws.amazon.com/apigateway/latest/developerguide/http-api-lambda-authorizer.html

#[derive(Deserialize)]
pub struct Request {
    pub headers: HashMap<String, String>,
}

#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct Response {
    pub is_authorized: bool,
    pub context: HashMap<String, String>,
}

#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct UnauthorizedResponse {
    pub error_message: String,
}

#[derive(Serialize)]
#[serde(untagged)]
pub enum AuthorizerResponse {
    PermissionResponse(Response),
    Unauthorized(UnauthorizedResponse),
}

#[tokio::main]
async fn main() -> Result<(), Error> {
    tracing::init_default_subscriber();

    let jwks_url = std::env::var("JWKS_URL").expect("JWKS_URL must be set");

    let jwks = token::get_jwks(jwks_url).await;

    println!("JWKS: {:?}", &jwks);

    run(service_fn(|ev| function_handler(&jwks, ev))).await
}

async fn function_handler(
    jwks: &JWKS,
    event: LambdaEvent<Request>,
) -> Result<AuthorizerResponse, Error> {
    let token_header = event.payload.headers.get("authorization");

    if token_header.is_none() {
        println!("No token header");
        return Ok(AuthorizerResponse::Unauthorized({
            UnauthorizedResponse {
                error_message: "Unauthorized".to_string(),
            }
        }));
    }

    let token = token_header.unwrap().replace("Bearer ", "");

    let claims = token::check_token(token.as_str(), jwks).await;
    // auth logic

    match claims {
        Ok(tk) => {
            println!("Claims: {:?}", tk);
            Ok(AuthorizerResponse::PermissionResponse(Response {
                is_authorized: true,
                context: HashMap::new(),
            }))
        }
        Err(e) => {
            println!("Error: {:?}", e);
            Ok(AuthorizerResponse::Unauthorized({
                UnauthorizedResponse {
                    error_message: "Unauthorized".to_string(),
                }
            }))
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

The only interesting part is that when I return isAuthorized as false, it is translated by API Gateway to a 403 status. It won't work with MCP flow, which requires a 401 status to trigger authorization discovery.

I have moved hardcoded things to env variables and deployed the project to AWS.

MCP Server

The last missing part is the actual MCP Server

For simplicity's sake, I take the Counter example from the examples for official Rust MCP SDK

Test

To test the whole solution, I add the new extension to goose and start the program

I can see the login window

Image description

After logging in, I am properly redirected to the localhost used by goose

Image description

Now I can use my MCP counter service that runs on Lambda behind AWS API Gateway. The connection is secured by the token obtained by the client from the WorkOS server

Image description

The whole code is available in this repo

Summary

In this blog post, I implemented the authorization service for the MCP Server using WorkOS as an identity provider. This way, clients using my server in their MCP clients would be able to log in using a third-party identity provider. This is a common flow in organizations that probably already have this provider set up.

At the moment, MCP clients don't fully implement the updated MCP schema, so even using Streamable HTTP type of servers requires some kind of workarounds, not to speak of authorization flow. I believe that with time, the ecosystem will mature, and providing clients with secure MCP servers will become more straightforward.

Top comments (3)

Collapse
 
youngfra profile image
Fraser Young

Did you know that serverless adoption on AWS Lambda grew by over 40% year-over-year in 2023? This shows how organizations are increasingly turning to solutions like Lambda for scalable and secure backend authentication flows, just like this MCP OAuth setup!

Collapse
 
nevodavid profile image
Nevo David

Been cool seeing steady progress - it adds up. what do you think actually keeps things growing over time? habits? luck? just showing up?

Some comments may only be visible to logged-in visitors. Sign in to view all comments.