Movatterモバイル変換


[0]ホーム

URL:


Skip to content
DEV Community
Log in Create account

DEV Community

Cover image for MCP OAuth on AWS Lambda with WorkOS
     

MCP OAuth on AWS Lambda with WorkOS

Photo byBruno Alves onUnsplash

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 wholecode 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 withgoose (open source AI client ) using themcp-remote package for handling aStreamable HTTP type of MCP server.

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

// authorizer/src/main.rsusestd::collections::HashMap;uselambda_runtime::{run,service_fn,tracing,Error,LambdaEvent};useserde::{Deserialize,Serialize};modgeneric_handler;// https://docs.aws.amazon.com/apigateway/latest/developerguide/http-api-lambda-authorizer.html#[derive(Deserialize)]pubstructRequest{pubheaders:HashMap<String,String>}#[derive(Serialize)]pubstructResponse{pubisAuthorized:bool,pubcontext:HashMap<String,String>}#[tokio::main]asyncfnmain()->Result<(),Error>{tracing::init_default_subscriber();run(service_fn(function_handler)).await}asyncfnfunction_handler(event:LambdaEvent<Request>)->Result<Response,Error>{// auth logicOk(Response{isAuthorized:false,context:HashMap::new()})}
Enter fullscreen modeExit fullscreen mode

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

// mcp-resource/src/main.rsuseaxum::{http::StatusCode,response::IntoResponse,routing::post,Router};usetower_http::trace::{DefaultMakeSpan,TraceLayer};usetracing_subscriber::EnvFilter;constPORT:u16=8080;#[tokio::main]asyncfnmain(){tracing_subscriber::fmt().with_env_filter(EnvFilter::try_from_default_env().unwrap_or_else(|_|EnvFilter::new("debug"))).init();letapp=Router::<()>::new().route("/mcp",post(mcp_handler)).layer(TraceLayer::new_for_http().make_span_with(DefaultMakeSpan::new().include_headers(true)));letlistener=tokio::net::TcpListener::bind(format!("0.0.0.0:{}",PORT)).await.unwrap();axum::serve(listener,app).await.unwrap();}pubasyncfnmcp_handler()->implIntoResponse{"hello"}
Enter fullscreen modeExit fullscreen mode

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

infrastructure/lib/infrastructure-stack.tsimport*ascdkfrom'aws-cdk-lib';import{Construct}from'constructs';import{RustFunction}from'@cdklabs/aws-lambda-rust'exportclassMCPAuthLambdaextendscdk.Stack{constructor(scope:Construct,id:string,props?:cdk.StackProps){super(scope,id,props);constauthorizer=newRustFunction(this,'Authorizer',{entry:'../authorizer/Cargo.toml',environment:{RUST_LOG:'info'}})constadapterLayer=cdk.aws_lambda.LayerVersion.fromLayerVersionArn(this,'WebAdapterLayer','arn:aws:lambda:us-east-1:753240598075:layer:LambdaAdapterLayerX86:25');constmcpResourceServer=newRustFunction(this,'McpResourceServer',{entry:'../mcp-resource',layers:[adapterLayer],environment:{RUST_LOG:'info'}})consthttpApi=newcdk.aws_apigatewayv2.HttpApi(this,'MCP-Auth-API');httpApi.addRoutes({path:'/mcp',authorizer:newcdk.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:newcdk.aws_apigatewayv2_integrations.HttpLambdaIntegration("mcp-resource",mcpResourceServer)});newcdk.CfnOutput(this,'ApiUrl',{value:httpApi.url!})}}
Enter fullscreen modeExit fullscreen mode

Local testing

I use SAM local to run APIs locally and test. To be able to do so with AWS CDK, I runcdk synth in theinfrastructure directory, and thensam local start-api -t MCPAuthLambda.template.json in thecdk.out directory.

Now I will add myMCP to thegoose by starting

goose configure
Enter fullscreen modeExit fullscreen mode

and addingmcp-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 tothe 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 thewell-known endpoint

// mcp-authorize/src/main.rsuseaxum::{debug_handler,response::IntoResponse,routing::get,Json,Router};constPORT:u16=8081;#[tokio::main]asyncfnmain(){letapp=Router::<()>::new().route("/.well-known/oauth-authorization-server",get(well_known_handler),);letlistener=tokio::net::TcpListener::bind(format!("0.0.0.0:{}",PORT)).await.unwrap();axum::serve(listener,app).await.unwrap();}#[debug_handler]asyncfnwell_known_handler()->implIntoResponse{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)]pubstructWellKnownAnswer{pubauthorization_endpoint:String,pubregistration_endpoint:String,pubgrant_types_supported:Vec<String>,pubscopes_supported:Vec<String>,pubresponse_modes_supported:Vec<String>,pubresponse_types_supported:Vec<String>,pubtoken_endpoint:String,pubissuer:String,}
Enter fullscreen modeExit fullscreen mode

I also updated the infrastructure with the newly created service

// ...constmcpAuthorizationServer=newRustFunction(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:newcdk.aws_apigatewayv2_integrations.HttpLambdaIntegration("mcp-authorization",mcpAuthorizationServer)});// ...
Enter fullscreen modeExit fullscreen mode

Rebuild the project withcdk synth and runsam local

Now, when startinggoose, 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 newclient_id each time, and I will use/register endpoint to plug the configuredWorkOS server.

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

According to theRFC 7591, theclient_id is the only required field in the response.
It turned out thatmcp-remote also requires theredirect_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// ...letapp=Router::<()>::new().route("/.well-known/oauth-authorization-server",get(well_known_handler),).route("/register",post(registration_handler));// ...#[debug_handler]asyncfnregistration_handler(Json(req):Json<ClientRegistrationRequest>)->implIntoResponse{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)]pubstructClientRegistrationRequest{pubclient_name:String,pubredirect_uris:Vec<String>,pubgrant_types:Vec<String>,pubtoken_endpoint_auth_method:String,pubresponse_types:Vec<String>,}#[derive(Serialize)]pubstructClientRegistrationAnswer{client_id:String,redirect_uris:Vec<String>,response_types:Vec<String>,grant_types:Vec<String>,client_name:String}//...
Enter fullscreen modeExit fullscreen mode

I add the endpoint to APIs

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

And startgoose 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 returnredirect to the user with the proper parameters, soWorkOS will perform heavy lifting.
I built the logic based on thedocumentation.
The only tricky part is that I can't simply use theredirect_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 specificredirect_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]asyncfnauthorization_handler(Query(params):Query<HashMap<String,String>>)->implIntoResponse{letoriginal_uri=params.get("redirect_uri").unwrap();letstate=BASE64_STANDARD.encode(original_uri);letlocal_redirect="http://localhost:3000/callback";leturl=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 modeExit fullscreen mode

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

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

Image description

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

/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 theauthorize action and redirect it to the originalredirect_uri stored in the requeststate

// ...#[debug_handler]asyncfncallback_handler(Query(params):Query<HashMap<String,String>>)->implIntoResponse{letcode=params.get("code").unwrap();letstate=params.get("state").unwrap();letoriginal_redirect_uri=String::from_utf8(BASE64_STANDARD.decode(state).unwrap()).unwrap();letresponse_url=reqwest::Url::parse_with_params(original_redirect_uri.as_str(),&[("code",&code),("state",&state)],).unwrap();Redirect::temporary(response_url.as_str())}// ...
Enter fullscreen modeExit 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 flowsauthorization_code andredresh_token. Token handler checks the type of the request, and goes to the WorkOS to get the actual token.

asyncfntoken_handler(State(state):State<AppState>,Form(form):Form<HashMap<String,String>>)->implIntoResponse{letgrant_type=form.get("grant_type").unwrap();letclient=reqwest::Client::new();letrequest=matchgrant_type.as_str(){"authorization_code"=>{letcode=form.get("code").unwrap();letcode_verifier=form.get("code_verifier").unwrap();letclient_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"=>{letrefresh_token=form.get("refresh_token").unwrap();letclient_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);letres=client.post("https://api.workos.com/user_management/authenticate").body(serde_json::to_string(&request).unwrap()).header("Content-Type","application/json").send().await.unwrap();letauthkit_response:AuthkitAuthResult=serde_json::from_str(res.text().await.unwrap().as_str()).unwrap();println!("token response {:?}",&authkit_response);letexpires_at=chrono::Utc::now().timestamp()+3600;lettoken_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)]pubstructTokenResponse{access_token:String,refresh_token:String,token_type:String,expires_at:i64,}#[derive(Serialize,Deserialize,Debug)]pubstructAuthkitAuthResult{access_token:String,refresh_token:String,}// ...
Enter fullscreen modeExit 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 withsam local start-api command.

Lambda authorizer is quite straightforward. I am gettingjwks and decodeJWT token.

// authorizer/src/token.rsusejsonwebtoken::{decode_header,DecodingKey,TokenData};useserde::{Deserialize,Serialize};#[derive(Debug,Serialize,Deserialize)]pubstructClaims{pubsub:String,pubexp:usize,}#[derive(Serialize,Deserialize,Clone,Debug)]pubstructJWK{pubkid:String,pubkty:String,pubalg:String,pubn:String,pube:String,}#[derive(Serialize,Deserialize,Clone,Debug)]pubstructJWKS{pubkeys:Vec<JWK>,}pubasyncfnget_jwks(jwks_url:String)->JWKS{letjwks_resp=reqwest::get(jwks_url).await.unwrap();letjwks:JWKS=jwks_resp.json().await.unwrap();jwks}pubasyncfncheck_token(token:&str,keys:&JWKS)->Result<TokenData<Claims>,String>{letheader=decode_header(token).unwrap();letkid=header.kid.ok_or("No kid found in token header")?;letjwk=keys.keys.iter().find(|k|k.kid==kid).ok_or("No matching kid found in jwks")?;letdecoding_key=DecodingKey::from_rsa_components(&jwk.n,&jwk.e).map_err(|op|format!("Error: {:?}",op))?;letmutvalidation=jsonwebtoken::Validation::new(jsonwebtoken::Algorithm::RS256);validation.validate_exp;lettoken_data=jsonwebtoken::decode::<Claims>(token,&decoding_key,&validation).map_err(|op|format!("Error: {:?}",op))?;println!("{:?}",token_data.claims);Ok(token_data)}
Enter fullscreen modeExit fullscreen mode

And the handler

// authorizer/src/main.rsusestd::collections::HashMap;uselambda_runtime::{run,service_fn,tracing,Error,LambdaEvent};useserde::{Deserialize,Serialize};useserde_json::{json,Value};usetoken::JWKS;modtoken;// https://docs.aws.amazon.com/apigateway/latest/developerguide/http-api-lambda-authorizer.html#[derive(Deserialize)]pubstructRequest{pubheaders:HashMap<String,String>,}#[derive(Serialize)]#[serde(rename_all="camelCase")]pubstructResponse{pubis_authorized:bool,pubcontext:HashMap<String,String>,}#[derive(Serialize)]#[serde(rename_all="camelCase")]pubstructUnauthorizedResponse{puberror_message:String,}#[derive(Serialize)]#[serde(untagged)]pubenumAuthorizerResponse{PermissionResponse(Response),Unauthorized(UnauthorizedResponse),}#[tokio::main]asyncfnmain()->Result<(),Error>{tracing::init_default_subscriber();letjwks_url=std::env::var("JWKS_URL").expect("JWKS_URL must be set");letjwks=token::get_jwks(jwks_url).await;println!("JWKS: {:?}",&jwks);run(service_fn(|ev|function_handler(&jwks,ev))).await}asyncfnfunction_handler(jwks:&JWKS,event:LambdaEvent<Request>,)->Result<AuthorizerResponse,Error>{lettoken_header=event.payload.headers.get("authorization");iftoken_header.is_none(){println!("No token header");returnOk(AuthorizerResponse::Unauthorized({UnauthorizedResponse{error_message:"Unauthorized".to_string(),}}));}lettoken=token_header.unwrap().replace("Bearer ","");letclaims=token::check_token(token.as_str(),jwks).await;// auth logicmatchclaims{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 modeExit fullscreen mode

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

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

MCP Server

The last missing part is the actual MCP Server

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

Test

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

I can see the login window

Image description

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

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 wholecode is available in this repo

Summary

In this blog post, I implemented the authorization service for the MCP Server usingWorkOS 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 usingStreamable 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)

Subscribe
pic
Create template

Templates let you quickly answer FAQs or store snippets for re-use.

Dismiss
CollapseExpand
 
youngfra profile image
Fraser Young
  • Joined

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!

CollapseExpand
 
nevodavid profile image
Nevo David
Founder of Postiz, an open-source social media scheduling tool.Running Gitroom, the best place to learn how to grow open-source tools.
  • Education
    Didn't finish high school :(
  • Pronouns
    Nev/Nevo
  • Work
    OSS Chief @ Gitroom
  • Joined

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.

Are you sure you want to hide this comment? It will become hidden in your post, but will still be visible via the comment'spermalink.

For further actions, you may consider blocking this person and/orreporting abuse

Build On!

Would you like to become an AWS Community Builder? Learn more about the program and apply to join when applications are open next.

More fromAWS Community Builders

DEV Community

We're a place where coders share, stay up-to-date and grow their careers.

Log in Create account

[8]ページ先頭

©2009-2025 Movatter.jp