Skip to main content

authplane_mcp/
lib.rs

1mod auth;
2
3pub use auth::{AuthplaneMcpAuth, authplane_mcp_auth_middleware, dpop_request_context_from_axum};
4
5use authplane_sdk::{AuthplaneError, build_url_elicitation_payload};
6use rmcp::model::ErrorData;
7
8/// Newtype wrapping the raw access-token JWT presented by the caller.
9///
10/// MCP tool handlers that delegate to AuthPlane's token-exchange flow need
11/// the original JWT for the `subject_token` field. The verified-claims
12/// extension (`VerifiedClaims`) intentionally drops the raw token, so
13/// middleware that authenticates a request should stash it alongside the
14/// claims as `RawAccessToken` for downstream extraction.
15///
16/// Transparent wrapper per the API convention documented in
17/// [`CONTRIBUTING.md`]: the inner `String` is `pub` because there are no
18/// invariants to enforce — the JWT shape is enforced by the verifier, not
19/// by this wrapper. Compare with `InboundDPoPOptions`, which carries
20/// validation invariants and keeps its fields private.
21///
22/// Exported here so consumers don't reinvent the newtype in each codebase.
23/// See `demo/http_calculator_demo.rs` for the wiring pattern.
24///
25/// [`CONTRIBUTING.md`]: https://github.com/AuthPlane/rust-sdk/blob/main/CONTRIBUTING.md
26#[derive(Clone, Debug)]
27pub struct RawAccessToken(pub String);
28
29impl RawAccessToken {
30    /// View the underlying JWT string.
31    pub fn as_str(&self) -> &str {
32        &self.0
33    }
34
35    /// Consume the wrapper and return the owned JWT string.
36    pub fn into_inner(self) -> String {
37        self.0
38    }
39}
40
41impl AsRef<str> for RawAccessToken {
42    fn as_ref(&self) -> &str {
43        &self.0
44    }
45}
46
47pub fn to_url_elicitation_required_error(
48    error: AuthplaneError,
49) -> Result<AuthplaneError, ErrorData> {
50    let AuthplaneError::ConsentRequired(consent) = &error else {
51        return Ok(error);
52    };
53
54    match build_url_elicitation_payload(consent) {
55        Some(payload) => Err(ErrorData::url_elicitation_required(
56            payload.message,
57            Some(payload.data),
58        )),
59        None => Ok(error),
60    }
61}
62
63pub async fn wrap_tool_with_url_elicitation<F, Fut, T>(handler: F) -> Result<T, ErrorData>
64where
65    F: FnOnce() -> Fut,
66    Fut: std::future::Future<Output = Result<T, AuthplaneError>>,
67{
68    match handler().await {
69        Ok(value) => Ok(value),
70        Err(error) => match to_url_elicitation_required_error(error) {
71            Ok(unmapped) => Err(ErrorData::internal_error(unmapped.to_string(), None)),
72            Err(mapped) => Err(mapped),
73        },
74    }
75}
76
77#[cfg(test)]
78mod tests {
79    use authplane_sdk::{AuthplaneError, ConsentRequiredError, parse_token_exchange_error};
80    use rmcp::model::ErrorCode;
81
82    use crate::{to_url_elicitation_required_error, wrap_tool_with_url_elicitation};
83
84    #[test]
85    fn maps_consent_error_to_url_elicitation() {
86        let input = AuthplaneError::from(ConsentRequiredError {
87            message: "Consent required".to_string(),
88            code: "consent_required".to_string(),
89            status_code: Some(400),
90            service_id: "drive".to_string(),
91            cause_detail: "approval_pending".to_string(),
92            consent_url: Some("https://example.com/consent".to_string()),
93        });
94
95        let mapped = to_url_elicitation_required_error(input);
96        let err = mapped.expect_err("must map to MCP url elicitation");
97
98        assert_eq!(err.code, ErrorCode::URL_ELICITATION_REQUIRED);
99        let data = err.data.expect("data should exist");
100        assert!(data["elicitations"][0]["url"] == "https://example.com/consent");
101    }
102
103    #[tokio::test]
104    async fn wrapper_passes_success() {
105        let result = wrap_tool_with_url_elicitation(|| async { Ok::<_, AuthplaneError>(42_u32) })
106            .await
107            .expect("must succeed");
108        assert_eq!(result, 42);
109    }
110
111    #[tokio::test]
112    async fn end_to_end_token_exchange_to_url_elicitation() {
113        let payload = r#"{
114            "error":"consent_required",
115            "error_description":"Consent needed",
116            "service_id":"calendar",
117            "cause":"approval_pending",
118            "consent_url":"https://example.com/consent"
119        }"#;
120
121        let result = wrap_tool_with_url_elicitation(|| async {
122            Err::<(), _>(parse_token_exchange_error(Some(400), payload))
123        })
124        .await;
125
126        let err = result.expect_err("must map to url elicitation");
127        assert_eq!(err.code, ErrorCode::URL_ELICITATION_REQUIRED);
128        assert_eq!(
129            err.data.expect("must include data")["elicitations"][0]["mode"],
130            "url"
131        );
132    }
133
134    #[test]
135    fn non_consent_error_is_passthrough() {
136        let input = AuthplaneError::Auth(authplane_sdk::AuthError {
137            message: "scope denied".to_string(),
138            code: "invalid_scope".to_string(),
139            status_code: Some(400),
140        });
141        let passthrough =
142            to_url_elicitation_required_error(input).expect("non-consent errors must pass through");
143        let AuthplaneError::Auth(auth_error) = passthrough else {
144            panic!("expected auth error passthrough")
145        };
146        assert_eq!(auth_error.code, "invalid_scope");
147    }
148
149    #[test]
150    fn consent_without_url_is_passthrough() {
151        let input = AuthplaneError::from(ConsentRequiredError {
152            message: "Consent required".to_string(),
153            code: "consent_required".to_string(),
154            status_code: Some(400),
155            service_id: "drive".to_string(),
156            cause_detail: "approval_pending".to_string(),
157            consent_url: None,
158        });
159        assert!(to_url_elicitation_required_error(input).is_ok());
160    }
161
162    #[test]
163    fn missing_fields_use_defaults() {
164        let input = AuthplaneError::from(ConsentRequiredError {
165            message: String::new(),
166            code: "consent_required".to_string(),
167            status_code: Some(400),
168            service_id: String::new(),
169            cause_detail: String::new(),
170            consent_url: Some("https://example.com/consent".to_string()),
171        });
172        let mapped = to_url_elicitation_required_error(input).expect_err("must map");
173        let data = mapped.data.expect("must include data");
174        let message = data["elicitations"][0]["message"]
175            .as_str()
176            .expect("message string");
177        assert!(message.contains("unknown_service"));
178        assert!(message.contains("Consent is required to proceed"));
179    }
180}