Skip to main content

netget/client/oauth2/
actions.rs

1//! OAuth2 client protocol actions implementation
2
3use 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
14/// OAuth2 client connected event
15pub 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
39/// OAuth2 token obtained event
40pub 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
82/// OAuth2 device code flow started event
83pub 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
119/// OAuth2 token error event
120pub 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
142/// OAuth2 client protocol action handler
143pub struct OAuth2ClientProtocol;
144
145impl OAuth2ClientProtocol {
146    pub fn new() -> Self {
147        Self
148    }
149}
150
151// Implement Protocol trait (common functionality)
152impl 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        // OAuth2 is typically request-response, so sync actions are the same as async
336        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            // LLM mode: LLM handles OAuth2 flows intelligently
428            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            // Script mode: Code-based OAuth2 handling
440            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            // Static mode: Fixed OAuth2 action
458            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
482// Implement Client trait (client-specific functionality)
483impl 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}