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#[derive(Clone, Debug)]
27pub struct RawAccessToken(pub String);
28
29impl RawAccessToken {
30 pub fn as_str(&self) -> &str {
32 &self.0
33 }
34
35 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}