{"slug": "oauth-proxy-rs", "title": "oauth_proxy.rs", "summary": "A developer implemented an OAuth2 proxy service in Rust to handle MCP server authentication, intercepting requests to Auth0 to provide static credentials and fix compliance issues. The service replaces the `audience` parameter with `resource` and overrides the audience to match the CORE API, enabling smooth token generation without dynamic client registration.", "body_md": "| /// Oauth2Service is a service that handles the proxying of oauth2 requests in the case of MCP server | |\n| /// We use/forward to Auth0 behind the scene but we need to intercept because: | |\n| /// * We don't want to support dynamic client registration in auth0, as we should enable it for the whole tenant and it causes security implications | |\n| /// *but* we wants users to use it without having to give them our oauth2 client and secret id before hands | |\n| /// *thus* we need to fake it in this server and returns our static creds. So it provides a smooth experience. Other implementations seems to do it too | |\n| /// There is new rfc/spec in progress to paliate to this https://auth0.com/blog/cimd-vs-dcr-mcp-registration/ | |\n| /// | |\n| /// * Auth0 is not Oauth2 MCP compliant by default. It uses `audience` as a parameter in the token request, but MCP/oauth2 expects `resource` | |\n| /// * We want users to generate a token for the audience/api of the CORE, *but* it is not possible by default as client check the audience/resource is the same | |\n| /// as the hostname of this server. So we intercept the request and replace the audience/resource with the audience of the CORE. | |\n| pub struct Oauth2Service { | |\n| original_oauth_server: Uri, | |\n| current_oauth_server: Uri, | |\n| oauth_client_id: String, | |\n| oauth_client_secret: String, | |\n| oauth_audience_override: String, | |\n| http_client: reqwest::Client, | |\n| response_auth_required: (StatusCode, HeaderMap), | |\n| response_oauth2_protected_resources: Arc<Value>, | |\n| } | |\n| impl Oauth2Service { | |\n| pub fn new( | |\n| original_oauth_server: Uri, | |\n| current_oauth_server: Uri, | |\n| oauth_client_id: String, | |\n| oauth_client_secret: String, | |\n| oauth_audience: String, | |\n| ) -> Arc<Self> { | |\n| let response_auth_required = { | |\n| let headers = HeaderMap::from_iter([ | |\n| (CONTENT_TYPE, HeaderValue::from_static(\"application/json\")), | |\n| (WWW_AUTHENTICATE, HeaderValue::from_str(&format!(\"Bearer error=\\\"authentification_required\\\" resource_metadata=\\\"{}.well-known/oauth-protected-resource\\\"\", current_oauth_server)).expect(\"Failed to format WWW-Authenticate header\")) | |\n| ]); | |\n| (StatusCode::UNAUTHORIZED, headers) | |\n| }; | |\n| let response_oauth2_protected_resources = Arc::new(json!({ | |\n| \"resource\": current_oauth_server.to_string(), | |\n| \"authorization_servers\": [ | |\n| current_oauth_server.to_string() | |\n| ], | |\n| \"scopes_supported\": [ | |\n| \"email\", \"offline_access\" | |\n| ], | |\n| \"bearer_methods_supported\": [ | |\n| \"header\" | |\n| ] | |\n| })); | |\n| let this = Self { | |\n| original_oauth_server, | |\n| current_oauth_server, | |\n| oauth_client_id, | |\n| oauth_client_secret, | |\n| oauth_audience_override: oauth_audience, | |\n| http_client: reqwest::Client::builder() | |\n| .timeout(Duration::from_secs(30)) | |\n| .connect_timeout(Duration::from_secs(10)) | |\n| .redirect(reqwest::redirect::Policy::none()) | |\n| .gzip(true) | |\n| .user_agent(\"Qovery MCP Server\") | |\n| .build() | |\n| .expect(\"Cannot build reqwest client\"), | |\n| response_auth_required, | |\n| response_oauth2_protected_resources, | |\n| }; | |\n| Arc::new(this) | |\n| } | |\n| pub fn axum_router(self: Arc<Self>) -> Router { | |\n| Router::new() | |\n| .route(\"/oauth/register\", post(Self::oidc_register)) | |\n| .route(\"/oauth/authorize\", get(Self::oauth2_authorize)) | |\n| .route(\"/oauth/token\", post(Self::oauth2_token_proxy)) | |\n| .route( | |\n| \"/.well-known/oauth-protected-resource\", | |\n| get(Self::oauth2_protected_resources), | |\n| ) | |\n| .route(\"/.well-known/oauth-authorization-server\", get(Self::oauth2_auth_server)) | |\n| .with_state(self) | |\n| } | |\n| pub fn response_auth_required(&self) -> axum::response::Response { | |\n| self.response_auth_required.clone().into_response() | |\n| } | |\n| async fn oidc_register( | |\n| State(srv): State<Arc<Oauth2Service>>, | |\n| Json(auth_request): Json<RegisterPayload>, | |\n| ) -> Json<Value> { | |\n| // const CALLBACK_URL: [&str; 2] = [ | |\n| // \"http://localhost:4242/callback\", | |\n| // \"https://claude.ai/api/mcp/auth_callback\", | |\n| // ]; | |\n| // | |\n| // if !auth_request | |\n| // .redirect_uris | |\n| // .iter() | |\n| // .any(|uri| CALLBACK_URL.iter().any(|callback| callback == uri)) | |\n| // { | |\n| // return Json(json!({ | |\n| // \"error\": \"invalid_redirect_uri\", | |\n| // \"description\": format!(\"The only redirect_uri/callback allowed is `{CALLBACK_URL:?}`. It is a limitation of our IDP provider (Auth0). Please pin the port with --callback-port in claude or use a token\") | |\n| // })); | |\n| // } | |\n| let json = json!({ | |\n| \"client_id\": srv.oauth_client_id, | |\n| \"client_secret\": srv.oauth_client_secret, | |\n| \"client_id_issued_at\": 1710000000, | |\n| \"client_name\": auth_request.client_name, | |\n| \"redirect_uris\": auth_request.redirect_uris, | |\n| \"grant_types\": auth_request.grant_types, | |\n| \"response_types\": auth_request.response_types, | |\n| \"token_endpoint_auth_method\": auth_request.token_endpoint_auth_method, | |\n| }); | |\n| Json(json) | |\n| } | |\n| // We intercept to inject correct audience/resource and ask to contact Auth0 to get the token | |\n| async fn oauth2_authorize(State(srv): State<Arc<Oauth2Service>>, req: Parts) -> Redirect { | |\n| info!(\"Oauth2 authorize request: {:?}\", req); | |\n| let query = req.uri.path_and_query().and_then(|p| p.query()).unwrap_or(\"\"); | |\n| let mut params: HashMap<String, String> = serde_urlencoded::from_str(query).unwrap_or_default(); | |\n| params.insert(\"resource\".to_string(), srv.oauth_audience_override.clone()); | |\n| params.insert(\"audience\".to_string(), srv.oauth_audience_override.clone()); | |\n| Redirect::to( | |\n| format!( | |\n| \"{}authorize?{}\", | |\n| srv.original_oauth_server, | |\n| serde_urlencoded::to_string(¶ms).unwrap_or_default() | |\n| ) | |\n| .as_str(), | |\n| ) | |\n| } | |\n| /// OAuth2 flow is done/user authentificated, we fetch the token from Auth0 with the correct audience/resource | |\n| /// We proxy and not redirect as the info is inside the body of the request. | |\n| async fn oauth2_token_proxy( | |\n| State(srv): State<Arc<Oauth2Service>>, | |\n| mut req: Parts, | |\n| body: Bytes, | |\n| ) -> Result<(StatusCode, http::HeaderMap, Bytes), (StatusCode, String)> { | |\n| trace!(\"Token proxy request: {:?} {:?}\", req, body); | |\n| const EMPTY_HEADER: fn() -> HeaderValue = || HeaderValue::from_static(\"\"); | |\n| let mut params: HashMap<String, String> = | |\n| serde_urlencoded::from_bytes(&body).map_err(|e| (StatusCode::BAD_REQUEST, e.to_string()))?; | |\n| params.insert(\"resource\".to_string(), srv.oauth_audience_override.clone()); | |\n| params.insert(\"audience\".to_string(), srv.oauth_audience_override.clone()); | |\n| let (content_type, auth) = { | |\n| let mut headers = std::mem::take(&mut req.headers); | |\n| ( | |\n| headers.remove(CONTENT_TYPE).unwrap_or(EMPTY_HEADER()), | |\n| headers.remove(AUTHORIZATION).unwrap_or(EMPTY_HEADER()), | |\n| ) | |\n| }; | |\n| let mut resp = srv | |\n| .http_client | |\n| .post(format!(\"{}oauth/token\", srv.original_oauth_server)) | |\n| .header(CONTENT_TYPE, content_type) | |\n| .header(AUTHORIZATION, auth) | |\n| .body(serde_urlencoded::to_string(¶ms).unwrap_or_default()) | |\n| .send() | |\n| .await | |\n| .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; | |\n| let status = resp.status(); | |\n| let headers = std::mem::take(resp.headers_mut()); | |\n| let body = resp | |\n| .bytes() | |\n| .await | |\n| .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; | |\n| Ok((status, headers, body)) | |\n| } | |\n| /// Advertise the oauth2 config of this server to the client. | |\n| /// The client uses this to know where to authenticate with the oauth2 server | |\n| async fn oauth2_protected_resources(State(srv): State<Arc<Oauth2Service>>, req: Request) -> Json<Arc<Value>> { | |\n| trace!(\"Oauth2 protected resource request: {:?}\", req); | |\n| Json(srv.response_oauth2_protected_resources.clone()) | |\n| } | |\n| /// We fetch the information about the auth server from Auth0. | |\n| /// And modify only the part we need for the client to contact us on specific endpoints | |\n| /// ie.:curl https://auth.qovery.com/.well-known/oauth-authorization-server | jq . | |\n| pub async fn oauth2_auth_server( | |\n| State(srv): State<Arc<Oauth2Service>>, | |\n| ) -> Result<Json<Value>, (StatusCode, String)> { | |\n| let response = srv | |\n| .http_client | |\n| .get(format!( | |\n| \"{}.well-known/oauth-authorization-server\", | |\n| srv.original_oauth_server | |\n| )) | |\n| .send() | |\n| .await | |\n| .map_err(|e| (StatusCode::BAD_REQUEST, e.to_string()))?; | |\n| trace!(\"Oauth2 auth server response: {:?}\", response); | |\n| let mut json: Value = response | |\n| .json() | |\n| .await | |\n| .map_err(|e| (StatusCode::BAD_REQUEST, e.to_string()))?; | |\n| *json.get_mut(\"token_endpoint\").unwrap_or(&mut Value::Null) = | |\n| Value::String(format!(\"{}oauth/token\", srv.current_oauth_server)); | |\n| *json.get_mut(\"authorization_endpoint\").unwrap_or(&mut Value::Null) = | |\n| Value::String(format!(\"{}oauth/authorize\", srv.current_oauth_server)); | |\n| *json.get_mut(\"registration_endpoint\").unwrap_or(&mut Value::Null) = | |\n| Value::String(format!(\"{}oauth/register\", srv.current_oauth_server)); | |\n| Ok(Json(json)) | |\n| } | |\n| } | |\n| #[derive(Default, Debug, Clone, PartialEq, Deserialize)] | |\n| struct RegisterPayload { | |\n| pub client_name: String, | |\n| pub grant_types: Vec<String>, | |\n| pub redirect_uris: Vec<String>, | |\n| pub response_types: Vec<String>, | |\n| pub token_endpoint_auth_method: String, | |\n| } |", "url": "https://wpnews.pro/news/oauth-proxy-rs", "canonical_source": "https://gist.github.com/erebe/a5de36d42214721b2466fb0e66f61c5e", "published_at": "2026-06-19 08:58:22+00:00", "updated_at": "2026-06-22 01:09:20.769409+00:00", "lang": "en", "topics": ["developer-tools"], "entities": ["Auth0", "MCP", "Oauth2Service", "Rust", "Qovery", "reqwest", "axum"], "alternates": {"html": "https://wpnews.pro/news/oauth-proxy-rs", "markdown": "https://wpnews.pro/news/oauth-proxy-rs.md", "text": "https://wpnews.pro/news/oauth-proxy-rs.txt", "jsonld": "https://wpnews.pro/news/oauth-proxy-rs.jsonld"}}