1use crate::llm::actions::{
4 client_trait::{Client, ClientActionResult},
5 protocol_trait::Protocol,
6 ActionDefinition, Parameter, ParameterDefinition,
7};
8use crate::protocol::EventType;
9use crate::state::app_state::AppState;
10use anyhow::{Context, Result};
11use serde_json::json;
12use std::sync::LazyLock;
13
14pub static OAUTH2_CLIENT_CONNECTED_EVENT: LazyLock<EventType> = LazyLock::new(|| {
16 EventType::new(
17 "oauth2_connected",
18 "OAuth2 client initialized and ready to authenticate",
19 json!({
20 "type": "refresh_token"
21 }),
22 )
23 .with_parameters(vec![
24 Parameter {
25 name: "token_url".to_string(),
26 type_hint: "string".to_string(),
27 description: "OAuth2 token endpoint URL".to_string(),
28 required: true,
29 },
30 Parameter {
31 name: "auth_url".to_string(),
32 type_hint: "string".to_string(),
33 description: "OAuth2 authorization endpoint URL (optional)".to_string(),
34 required: false,
35 },
36 ])
37});
38
39pub static OAUTH2_TOKEN_OBTAINED_EVENT: LazyLock<EventType> = LazyLock::new(|| {
41 EventType::new(
42 "oauth2_token_obtained",
43 "OAuth2 access token successfully obtained",
44 json!({
45 "type": "refresh_token"
46 }),
47 )
48 .with_parameters(vec![
49 Parameter {
50 name: "access_token".to_string(),
51 type_hint: "string".to_string(),
52 description: "The access token (redacted for security)".to_string(),
53 required: true,
54 },
55 Parameter {
56 name: "token_type".to_string(),
57 type_hint: "string".to_string(),
58 description: "Token type (usually 'Bearer')".to_string(),
59 required: true,
60 },
61 Parameter {
62 name: "expires_in".to_string(),
63 type_hint: "number".to_string(),
64 description: "Token expiration time in seconds".to_string(),
65 required: false,
66 },
67 Parameter {
68 name: "refresh_token".to_string(),
69 type_hint: "string".to_string(),
70 description: "Refresh token (redacted for security)".to_string(),
71 required: false,
72 },
73 Parameter {
74 name: "scope".to_string(),
75 type_hint: "string".to_string(),
76 description: "Granted scopes".to_string(),
77 required: false,
78 },
79 ])
80});
81
82pub static OAUTH2_DEVICE_CODE_EVENT: LazyLock<EventType> = LazyLock::new(|| {
84 EventType::new(
85 "oauth2_device_code_started",
86 "Device code flow initiated, user needs to visit URL",
87 json!({
88 "type": "refresh_token"
89 }),
90 )
91 .with_parameters(vec![
92 Parameter {
93 name: "verification_uri".to_string(),
94 type_hint: "string".to_string(),
95 description: "URL the user must visit".to_string(),
96 required: true,
97 },
98 Parameter {
99 name: "user_code".to_string(),
100 type_hint: "string".to_string(),
101 description: "Code the user must enter".to_string(),
102 required: true,
103 },
104 Parameter {
105 name: "device_code".to_string(),
106 type_hint: "string".to_string(),
107 description: "Device code for polling (internal)".to_string(),
108 required: true,
109 },
110 Parameter {
111 name: "interval".to_string(),
112 type_hint: "number".to_string(),
113 description: "Polling interval in seconds".to_string(),
114 required: false,
115 },
116 ])
117});
118
119pub static OAUTH2_ERROR_EVENT: LazyLock<EventType> = LazyLock::new(|| {
121 EventType::new(
122 "oauth2_error",
123 "OAuth2 authentication error occurred",
124 json!({"type": "placeholder", "event_id": "oauth2_error"}),
125 )
126 .with_parameters(vec![
127 Parameter {
128 name: "error".to_string(),
129 type_hint: "string".to_string(),
130 description: "Error code".to_string(),
131 required: true,
132 },
133 Parameter {
134 name: "error_description".to_string(),
135 type_hint: "string".to_string(),
136 description: "Human-readable error description".to_string(),
137 required: false,
138 },
139 ])
140});
141
142pub struct OAuth2ClientProtocol;
144
145impl OAuth2ClientProtocol {
146 pub fn new() -> Self {
147 Self
148 }
149}
150
151impl Protocol for OAuth2ClientProtocol {
153 fn get_startup_parameters(&self) -> Vec<ParameterDefinition> {
154 vec![
155 ParameterDefinition {
156 name: "client_id".to_string(),
157 description: "OAuth2 client ID".to_string(),
158 type_hint: "string".to_string(),
159 required: true,
160 example: json!("my-client-id"),
161 default: None,
162 },
163 ParameterDefinition {
164 name: "client_secret".to_string(),
165 description: "OAuth2 client secret".to_string(),
166 type_hint: "string".to_string(),
167 required: false,
168 example: json!("my-client-secret"),
169 default: None,
170 },
171 ParameterDefinition {
172 name: "auth_url".to_string(),
173 description: "OAuth2 authorization endpoint URL".to_string(),
174 type_hint: "string".to_string(),
175 required: false,
176 example: json!("https://provider.com/oauth/authorize"),
177 default: None,
178 },
179 ParameterDefinition {
180 name: "token_url".to_string(),
181 description: "OAuth2 token endpoint URL".to_string(),
182 type_hint: "string".to_string(),
183 required: true,
184 example: json!("https://provider.com/oauth/token"),
185 default: None,
186 },
187 ParameterDefinition {
188 name: "scopes".to_string(),
189 description: "OAuth2 scopes to request (space-separated or array)".to_string(),
190 type_hint: "string".to_string(),
191 required: false,
192 example: json!("read write"),
193 default: None,
194 },
195 ]
196 }
197 fn get_async_actions(&self, _state: &AppState) -> Vec<ActionDefinition> {
198 vec![
199 ActionDefinition {
200 name: "exchange_password".to_string(),
201 description: "Exchange username/password for access token (Resource Owner Password Credentials flow)".to_string(),
202 parameters: vec![
203 Parameter {
204 name: "username".to_string(),
205 type_hint: "string".to_string(),
206 description: "Username".to_string(),
207 required: true,
208 },
209 Parameter {
210 name: "password".to_string(),
211 type_hint: "string".to_string(),
212 description: "Password".to_string(),
213 required: true,
214 },
215 Parameter {
216 name: "scopes".to_string(),
217 type_hint: "string".to_string(),
218 description: "Space-separated scopes".to_string(),
219 required: false,
220 },
221 ],
222 example: json!({
223 "type": "exchange_password",
224 "username": "user@example.com",
225 "password": "secret123",
226 "scopes": "read write"
227 }),
228 log_template: None,
229 },
230 ActionDefinition {
231 name: "exchange_client_credentials".to_string(),
232 description: "Exchange client credentials for access token (Client Credentials flow)".to_string(),
233 parameters: vec![
234 Parameter {
235 name: "scopes".to_string(),
236 type_hint: "string".to_string(),
237 description: "Space-separated scopes".to_string(),
238 required: false,
239 },
240 ],
241 example: json!({
242 "type": "exchange_client_credentials",
243 "scopes": "api.read api.write"
244 }),
245 log_template: None,
246 },
247 ActionDefinition {
248 name: "start_device_code_flow".to_string(),
249 description: "Start device code flow for CLI authentication".to_string(),
250 parameters: vec![
251 Parameter {
252 name: "scopes".to_string(),
253 type_hint: "string".to_string(),
254 description: "Space-separated scopes".to_string(),
255 required: false,
256 },
257 ],
258 example: json!({
259 "type": "start_device_code_flow",
260 "scopes": "read"
261 }),
262 log_template: None,
263 },
264 ActionDefinition {
265 name: "poll_device_code".to_string(),
266 description: "Poll for device code flow completion (internal, called automatically)".to_string(),
267 parameters: vec![],
268 example: json!({
269 "type": "poll_device_code"
270 }),
271 log_template: None,
272 },
273 ActionDefinition {
274 name: "refresh_token".to_string(),
275 description: "Refresh access token using refresh token".to_string(),
276 parameters: vec![],
277 example: json!({
278 "type": "refresh_token"
279 }),
280 log_template: None,
281 },
282 ActionDefinition {
283 name: "generate_auth_url".to_string(),
284 description: "Generate authorization URL for authorization code flow".to_string(),
285 parameters: vec![
286 Parameter {
287 name: "scopes".to_string(),
288 type_hint: "string".to_string(),
289 description: "Space-separated scopes".to_string(),
290 required: false,
291 },
292 Parameter {
293 name: "redirect_uri".to_string(),
294 type_hint: "string".to_string(),
295 description: "Redirect URI (e.g., http://localhost:8080/callback)".to_string(),
296 required: false,
297 },
298 ],
299 example: json!({
300 "type": "generate_auth_url",
301 "scopes": "read write",
302 "redirect_uri": "http://localhost:8080/callback"
303 }),
304 log_template: None,
305 },
306 ActionDefinition {
307 name: "exchange_code".to_string(),
308 description: "Exchange authorization code for access token".to_string(),
309 parameters: vec![
310 Parameter {
311 name: "code".to_string(),
312 type_hint: "string".to_string(),
313 description: "Authorization code from callback".to_string(),
314 required: true,
315 },
316 ],
317 example: json!({
318 "type": "exchange_code",
319 "code": "auth-code-here"
320 }),
321 log_template: None,
322 },
323 ActionDefinition {
324 name: "disconnect".to_string(),
325 description: "Disconnect OAuth2 client".to_string(),
326 parameters: vec![],
327 example: json!({
328 "type": "disconnect"
329 }),
330 log_template: None,
331 },
332 ]
333 }
334 fn get_sync_actions(&self) -> Vec<ActionDefinition> {
335 vec![ActionDefinition {
337 name: "refresh_token".to_string(),
338 description: "Refresh access token in response to token expiration".to_string(),
339 parameters: vec![],
340 example: json!({
341 "type": "refresh_token"
342 }),
343 log_template: None,
344 }]
345 }
346 fn protocol_name(&self) -> &'static str {
347 "OAuth2"
348 }
349 fn get_event_types(&self) -> Vec<EventType> {
350 vec![
351 EventType::new(
352 "oauth2_connected",
353 "Triggered when OAuth2 client is initialized",
354 json!({"type": "placeholder", "event_id": "oauth2_connected"}),
355 ),
356 EventType::new(
357 "oauth2_token_obtained",
358 "Triggered when access token is obtained",
359 json!({"type": "placeholder", "event_id": "oauth2_token_obtained"}),
360 ),
361 EventType::new(
362 "oauth2_device_code_started",
363 "Triggered when device code flow is initiated",
364 json!({"type": "placeholder", "event_id": "oauth2_device_code_started"}),
365 ),
366 EventType::new(
367 "oauth2_error",
368 "Triggered when OAuth2 error occurs",
369 json!({"type": "placeholder", "event_id": "oauth2_error"}),
370 ),
371 ]
372 }
373 fn stack_name(&self) -> &'static str {
374 "ETH>IP>TCP>HTTP>OAuth2"
375 }
376 fn keywords(&self) -> Vec<&'static str> {
377 vec![
378 "oauth2",
379 "oauth",
380 "authentication",
381 "access token",
382 "oauth2 client",
383 ]
384 }
385 fn metadata(&self) -> crate::protocol::metadata::ProtocolMetadataV2 {
386 use crate::protocol::metadata::{DevelopmentState, ProtocolMetadataV2};
387
388 ProtocolMetadataV2::builder()
389 .state(DevelopmentState::Experimental)
390 .implementation(
391 "oauth2 crate 4.4 for the password, client-credentials and \
392 authorization-code flows; device code (RFC 8628) by direct HTTP, which \
393 the crate does not expose. token_url is required and connect() refuses \
394 without it, so the SDK's own endpoint defaults can never decide where \
395 traffic goes.",
396 )
397 .llm_control("Which flow to run, when to refresh, and what to do with the result")
398 .e2e_testing(
399 "tests/client/oauth2/command_channel_test.rs drives the injected-action \
400 path against a provider served in-process, and is the only part that \
401 runs. Four of the five tests in e2e_test.rs are #[ignore]d for Ollama \
402 AND carry a skip-when-missing gate on the `mcp` feature (for axum), so \
403 they are a silent pass twice over; the fifth is a construction smoke \
404 test that contacts nothing.",
405 )
406 .notes(
407 "Tokens reach the model only as \"[REDACTED]\" - the values live in \
408 protocol_data and the actions read them back. No token is verified: an \
409 access token is an opaque string to this client.",
410 )
411 .build()
412 }
413 fn description(&self) -> &'static str {
414 "OAuth2 client for authentication and token management"
415 }
416 fn example_prompt(&self) -> &'static str {
417 "Authenticate with OAuth2 using password flow: username 'user@example.com', password 'secret'"
418 }
419 fn group_name(&self) -> &'static str {
420 "Authentication"
421 }
422 fn get_startup_examples(&self) -> crate::llm::actions::StartupExamples {
423 use crate::llm::actions::StartupExamples;
424 use serde_json::json;
425
426 StartupExamples::new(
427 json!({
429 "type": "open_client",
430 "remote_addr": "https://provider.example.com/oauth",
431 "base_stack": "oauth2",
432 "instruction": "Authenticate using client credentials flow to get an access token",
433 "startup_params": {
434 "client_id": "my-client-id",
435 "client_secret": "my-client-secret",
436 "token_url": "https://provider.example.com/oauth/token"
437 }
438 }),
439 json!({
441 "type": "open_client",
442 "remote_addr": "https://provider.example.com/oauth",
443 "base_stack": "oauth2",
444 "startup_params": {
445 "client_id": "my-client-id",
446 "token_url": "https://provider.example.com/oauth/token"
447 },
448 "event_handlers": [{
449 "event_pattern": "oauth2_connected",
450 "handler": {
451 "type": "script",
452 "language": "python",
453 "code": "<oauth2_client_handler>"
454 }
455 }]
456 }),
457 json!({
459 "type": "open_client",
460 "remote_addr": "https://provider.example.com/oauth",
461 "base_stack": "oauth2",
462 "startup_params": {
463 "client_id": "my-client-id",
464 "client_secret": "my-client-secret",
465 "token_url": "https://provider.example.com/oauth/token"
466 },
467 "event_handlers": [{
468 "event_pattern": "oauth2_connected",
469 "handler": {
470 "type": "static",
471 "actions": [{
472 "type": "exchange_client_credentials",
473 "scopes": "api.read api.write"
474 }]
475 }
476 }]
477 }),
478 )
479 }
480}
481
482impl Client for OAuth2ClientProtocol {
484 fn connect(
485 &self,
486 ctx: crate::protocol::ConnectContext,
487 ) -> std::pin::Pin<
488 Box<dyn std::future::Future<Output = anyhow::Result<std::net::SocketAddr>> + Send>,
489 > {
490 Box::pin(async move {
491 use crate::client::oauth2::OAuth2Client;
492 OAuth2Client::connect_with_llm_actions(
493 ctx.remote_addr,
494 ctx.llm_client,
495 ctx.state,
496 ctx.status_tx,
497 ctx.client_id,
498 )
499 .await
500 })
501 }
502 fn execute_action(&self, action: serde_json::Value) -> Result<ClientActionResult> {
503 let action_type = action
504 .get("type")
505 .and_then(|v| v.as_str())
506 .context("Missing 'type' field in action")?;
507
508 match action_type {
509 "exchange_password" => {
510 let username = action
511 .get("username")
512 .and_then(|v| v.as_str())
513 .context("Missing 'username' field")?
514 .to_string();
515
516 let password = action
517 .get("password")
518 .and_then(|v| v.as_str())
519 .context("Missing 'password' field")?
520 .to_string();
521
522 let scopes = action
523 .get("scopes")
524 .and_then(|v| v.as_str())
525 .map(|s| s.to_string());
526
527 Ok(ClientActionResult::Custom {
528 name: "oauth2_exchange_password".to_string(),
529 data: json!({
530 "username": username,
531 "password": password,
532 "scopes": scopes,
533 }),
534 })
535 }
536 "exchange_client_credentials" => {
537 let scopes = action
538 .get("scopes")
539 .and_then(|v| v.as_str())
540 .map(|s| s.to_string());
541
542 Ok(ClientActionResult::Custom {
543 name: "oauth2_exchange_client_credentials".to_string(),
544 data: json!({
545 "scopes": scopes,
546 }),
547 })
548 }
549 "start_device_code_flow" => {
550 let scopes = action
551 .get("scopes")
552 .and_then(|v| v.as_str())
553 .map(|s| s.to_string());
554
555 Ok(ClientActionResult::Custom {
556 name: "oauth2_start_device_code".to_string(),
557 data: json!({
558 "scopes": scopes,
559 }),
560 })
561 }
562 "poll_device_code" => Ok(ClientActionResult::Custom {
563 name: "oauth2_poll_device_code".to_string(),
564 data: json!({}),
565 }),
566 "refresh_token" => Ok(ClientActionResult::Custom {
567 name: "oauth2_refresh_token".to_string(),
568 data: json!({}),
569 }),
570 "generate_auth_url" => {
571 let scopes = action
572 .get("scopes")
573 .and_then(|v| v.as_str())
574 .map(|s| s.to_string());
575
576 let redirect_uri = action
577 .get("redirect_uri")
578 .and_then(|v| v.as_str())
579 .map(|s| s.to_string());
580
581 Ok(ClientActionResult::Custom {
582 name: "oauth2_generate_auth_url".to_string(),
583 data: json!({
584 "scopes": scopes,
585 "redirect_uri": redirect_uri,
586 }),
587 })
588 }
589 "exchange_code" => {
590 let code = action
591 .get("code")
592 .and_then(|v| v.as_str())
593 .context("Missing 'code' field")?
594 .to_string();
595
596 Ok(ClientActionResult::Custom {
597 name: "oauth2_exchange_code".to_string(),
598 data: json!({
599 "code": code,
600 }),
601 })
602 }
603 "disconnect" => Ok(ClientActionResult::Disconnect),
604 _ => Err(anyhow::anyhow!(
605 "Unknown OAuth2 client action: {}",
606 action_type
607 )),
608 }
609 }
610}