Skip to main content

netget/client/openidconnect/
actions.rs

1//! OpenID Connect 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/// OpenID Connect client discovered configuration event
15pub static OIDC_CLIENT_DISCOVERED_EVENT: LazyLock<EventType> = LazyLock::new(|| {
16    EventType::new(
17        "oidc_discovered",
18        "OpenID Connect provider configuration discovered",
19        json!({
20            "type": "fetch_userinfo"
21        }),
22    )
23    .with_parameters(vec![
24        Parameter {
25            name: "issuer".to_string(),
26            type_hint: "string".to_string(),
27            description: "OpenID Connect provider issuer URL".to_string(),
28            required: true,
29        },
30        Parameter {
31            name: "authorization_endpoint".to_string(),
32            type_hint: "string".to_string(),
33            description: "Authorization endpoint URL".to_string(),
34            required: true,
35        },
36        Parameter {
37            name: "token_endpoint".to_string(),
38            type_hint: "string".to_string(),
39            description: "Token endpoint URL".to_string(),
40            required: true,
41        },
42        Parameter {
43            name: "userinfo_endpoint".to_string(),
44            type_hint: "string".to_string(),
45            description: "UserInfo endpoint URL".to_string(),
46            required: false,
47        },
48        Parameter {
49            name: "supported_scopes".to_string(),
50            type_hint: "array".to_string(),
51            description: "Supported OAuth scopes".to_string(),
52            required: false,
53        },
54    ])
55});
56
57/// OpenID Connect client token received event
58pub static OIDC_CLIENT_TOKEN_RECEIVED_EVENT: LazyLock<EventType> = LazyLock::new(|| {
59    EventType::new(
60        "oidc_token_received",
61        "OAuth/OIDC tokens received from provider",
62        json!({
63            "type": "fetch_userinfo"
64        }),
65    )
66    .with_parameters(vec![
67        // The three secrets are reported as `[REDACTED]` (or `""` when absent), never
68        // verbatim: the client stores them in `protocol_data` and every action that needs one
69        // reads it back, so the model has no use for the value and putting it here copied a
70        // live bearer token into the LLM prompt, the log file and the status stream.
71        Parameter {
72            name: "access_token".to_string(),
73            type_hint: "string".to_string(),
74            description: "\"[REDACTED]\" - an access token was received. The value is held by \
75                          the client; name it in an action (fetch_userinfo, validate_token) \
76                          rather than trying to repeat it."
77                .to_string(),
78            required: true,
79        },
80        Parameter {
81            name: "id_token".to_string(),
82            type_hint: "string".to_string(),
83            description: "\"[REDACTED]\" if the provider returned an ID token, \"\" if not. \
84                          NetGet does not verify its signature, issuer, audience or nonce."
85                .to_string(),
86            required: false,
87        },
88        Parameter {
89            name: "refresh_token".to_string(),
90            type_hint: "string".to_string(),
91            description: "\"[REDACTED]\" if a refresh token was received, \"\" if not. Use the \
92                          refresh_token action; the value itself is held by the client."
93                .to_string(),
94            required: false,
95        },
96        Parameter {
97            name: "expires_in".to_string(),
98            type_hint: "number".to_string(),
99            description: "Token expiration time in seconds".to_string(),
100            required: false,
101        },
102        Parameter {
103            name: "token_type".to_string(),
104            type_hint: "string".to_string(),
105            description: "Token type (usually 'Bearer')".to_string(),
106            required: true,
107        },
108    ])
109});
110
111/// OpenID Connect client userinfo received event
112pub static OIDC_CLIENT_USERINFO_RECEIVED_EVENT: LazyLock<EventType> = LazyLock::new(|| {
113    EventType::new(
114        "oidc_userinfo_received",
115        "UserInfo data received from OpenID Connect provider",
116        json!({
117            "type": "refresh_token"
118        }),
119    )
120    .with_parameters(vec![
121        Parameter {
122            name: "sub".to_string(),
123            type_hint: "string".to_string(),
124            description: "Subject identifier (user ID)".to_string(),
125            required: true,
126        },
127        Parameter {
128            name: "claims".to_string(),
129            type_hint: "object".to_string(),
130            description: "User claims (name, email, etc.)".to_string(),
131            required: true,
132        },
133    ])
134});
135
136/// OpenID Connect client protocol action handler
137pub struct OpenIdConnectClientProtocol;
138
139impl OpenIdConnectClientProtocol {
140    pub fn new() -> Self {
141        Self
142    }
143}
144
145// Implement Protocol trait (common functionality)
146impl Protocol for OpenIdConnectClientProtocol {
147    fn get_startup_parameters(&self) -> Vec<ParameterDefinition> {
148        vec![
149                ParameterDefinition {
150                    name: "client_id".to_string(),
151                    description: "OAuth2/OIDC client ID".to_string(),
152                    type_hint: "string".to_string(),
153                    required: true,
154                    example: json!("my-application-id"),
155                    default: None,
156                },
157                ParameterDefinition {
158                    name: "client_secret".to_string(),
159                    description: "OAuth2/OIDC client secret (if using confidential client)".to_string(),
160                    type_hint: "string".to_string(),
161                    required: false,
162                    example: json!("secret-key-12345"),
163                    default: None,
164                },
165                ParameterDefinition {
166                    name: "redirect_uri".to_string(),
167                    description: "OAuth2 redirect URI for authorization code flow".to_string(),
168                    type_hint: "string".to_string(),
169                    required: false,
170                    example: json!("http://localhost:8080/callback"),
171                    default: None,
172                },
173                ParameterDefinition {
174                    name: "scopes".to_string(),
175                    description: "OAuth2 scopes to request (space-separated)".to_string(),
176                    type_hint: "string".to_string(),
177                    required: false,
178                    example: json!("openid profile email"),
179                    default: None,
180                },
181                ParameterDefinition {
182                    name: "flow".to_string(),
183                    description: "OAuth2/OIDC flow type (device_code, password, client_credentials, authorization_code)".to_string(),
184                    type_hint: "string".to_string(),
185                    required: false,
186                    example: json!("device_code"),
187                    default: None,
188                },
189            ]
190    }
191    fn get_async_actions(&self, _state: &AppState) -> Vec<ActionDefinition> {
192        vec![
193                ActionDefinition {
194                    name: "discover_configuration".to_string(),
195                    description: "Discover OpenID Connect provider configuration from .well-known/openid-configuration".to_string(),
196                    parameters: vec![],
197                    example: json!({
198                        "type": "discover_configuration"
199                    }),
200                log_template: None,
201                },
202                ActionDefinition {
203                    name: "start_device_flow".to_string(),
204                    description: "Start OAuth2 device code flow for CLI authentication".to_string(),
205                    parameters: vec![
206                        Parameter {
207                            name: "scopes".to_string(),
208                            type_hint: "string".to_string(),
209                            description: "Space-separated OAuth scopes".to_string(),
210                            required: false,
211                        },
212                    ],
213                    example: json!({
214                        "type": "start_device_flow",
215                        "scopes": "openid profile email"
216                    }),
217                log_template: None,
218                },
219                ActionDefinition {
220                    name: "start_authorization_code_flow".to_string(),
221                    description: "Start OAuth2 authorization code flow with local HTTP callback server".to_string(),
222                    parameters: vec![
223                        Parameter {
224                            name: "scopes".to_string(),
225                            type_hint: "string".to_string(),
226                            description: "Space-separated OAuth scopes".to_string(),
227                            required: false,
228                        },
229                        Parameter {
230                            name: "port".to_string(),
231                            type_hint: "number".to_string(),
232                            description: "Local callback server port (default: 8080)".to_string(),
233                            required: false,
234                        },
235                    ],
236                    example: json!({
237                        "type": "start_authorization_code_flow",
238                        "scopes": "openid profile email",
239                        "port": 8080
240                    }),
241                log_template: None,
242                },
243                ActionDefinition {
244                    name: "exchange_password".to_string(),
245                    description: "Exchange username/password for tokens (Resource Owner Password Credentials flow)".to_string(),
246                    parameters: vec![
247                        Parameter {
248                            name: "username".to_string(),
249                            type_hint: "string".to_string(),
250                            description: "Username".to_string(),
251                            required: true,
252                        },
253                        Parameter {
254                            name: "password".to_string(),
255                            type_hint: "string".to_string(),
256                            description: "Password".to_string(),
257                            required: true,
258                        },
259                        Parameter {
260                            name: "scopes".to_string(),
261                            type_hint: "string".to_string(),
262                            description: "Space-separated OAuth scopes".to_string(),
263                            required: false,
264                        },
265                    ],
266                    example: json!({
267                        "type": "exchange_password",
268                        "username": "user@example.com",
269                        "password": "secret123",
270                        "scopes": "openid profile"
271                    }),
272                log_template: None,
273                },
274                ActionDefinition {
275                    name: "exchange_client_credentials".to_string(),
276                    description: "Exchange client credentials for access token (machine-to-machine)".to_string(),
277                    parameters: vec![
278                        Parameter {
279                            name: "scopes".to_string(),
280                            type_hint: "string".to_string(),
281                            description: "Space-separated OAuth scopes".to_string(),
282                            required: false,
283                        },
284                    ],
285                    example: json!({
286                        "type": "exchange_client_credentials",
287                        "scopes": "api.read api.write"
288                    }),
289                log_template: None,
290                },
291                ActionDefinition {
292                    name: "refresh_token".to_string(),
293                    description: "Refresh access token using refresh token".to_string(),
294                    parameters: vec![],
295                    example: json!({
296                        "type": "refresh_token"
297                    }),
298                log_template: None,
299                },
300                ActionDefinition {
301                    name: "fetch_userinfo".to_string(),
302                    description: "Fetch user information from UserInfo endpoint using access token".to_string(),
303                    parameters: vec![],
304                    example: json!({
305                        "type": "fetch_userinfo"
306                    }),
307                log_template: None,
308                },
309                ActionDefinition {
310                    name: "disconnect".to_string(),
311                    description: "Disconnect from the OpenID Connect provider".to_string(),
312                    parameters: vec![],
313                    example: json!({
314                        "type": "disconnect"
315                    }),
316                log_template: None,
317                },
318            ]
319    }
320    fn get_sync_actions(&self) -> Vec<ActionDefinition> {
321        vec![
322            ActionDefinition {
323                name: "fetch_userinfo".to_string(),
324                description: "Fetch user information after receiving tokens".to_string(),
325                parameters: vec![],
326                example: json!({
327                    "type": "fetch_userinfo"
328                }),
329                log_template: None,
330            },
331            ActionDefinition {
332                name: "refresh_token".to_string(),
333                description: "Refresh access token in response to expiration".to_string(),
334                parameters: vec![],
335                example: json!({
336                    "type": "refresh_token"
337                }),
338                log_template: None,
339            },
340        ]
341    }
342    fn protocol_name(&self) -> &'static str {
343        "OpenIDConnect"
344    }
345    fn get_event_types(&self) -> Vec<EventType> {
346        vec![
347            EventType::new(
348                "oidc_discovered",
349                "Triggered when OIDC provider configuration is discovered",
350                json!({"type": "placeholder", "event_id": "oidc_discovered"}),
351            ),
352            EventType::new(
353                "oidc_token_received",
354                "Triggered when OAuth/OIDC tokens are received",
355                json!({"type": "placeholder", "event_id": "oidc_token_received"}),
356            ),
357            EventType::new(
358                "oidc_userinfo_received",
359                "Triggered when UserInfo data is received",
360                json!({"type": "placeholder", "event_id": "oidc_userinfo_received"}),
361            ),
362        ]
363    }
364    fn stack_name(&self) -> &'static str {
365        "ETH>IP>TCP>HTTP>OIDC"
366    }
367    fn keywords(&self) -> Vec<&'static str> {
368        vec!["openidconnect", "oidc", "openid connect", "oauth2 client"]
369    }
370    fn metadata(&self) -> crate::protocol::metadata::ProtocolMetadataV2 {
371        use crate::protocol::metadata::{DevelopmentState, ProtocolMetadataV2};
372
373        ProtocolMetadataV2::builder()
374            .state(DevelopmentState::Experimental)
375            .implementation(
376                "openidconnect 3.5. Discovery, authorization-code, device-code, password \
377                     and client-credentials flows are driven; tokens are stored opaquely and \
378                     nothing about them is verified.",
379            )
380            .llm_control("Which flow to run, when to refresh, and what to do with the result")
381            .e2e_testing(
382                "tests/client/openidconnect/command_channel_test.rs drives the \
383                     injected-action path with no provider. The five tests in e2e_test.rs are \
384                     #[ignore]d because they point at the real accounts.google.com and need \
385                     --use-ollama, so they run nowhere and prove nothing.",
386            )
387            .notes(
388                "No ID token is verified. The openidconnect crate can check a JWT's \
389                     signature, issuer, audience and nonce through id_token.claims(..); this \
390                     client never calls it and stores the token as an opaque string, so a \
391                     forged or expired id_token is accepted exactly as readily as a genuine \
392                     one. Access, refresh and ID tokens reach the model only as \"[REDACTED]\".",
393            )
394            .build()
395    }
396    fn description(&self) -> &'static str {
397        "OpenID Connect client for OAuth2/OIDC authentication"
398    }
399    fn example_prompt(&self) -> &'static str {
400        "Connect to OpenID Connect provider at https://accounts.google.com and authenticate"
401    }
402    fn group_name(&self) -> &'static str {
403        "Authentication"
404    }
405    fn get_startup_examples(&self) -> crate::llm::actions::StartupExamples {
406        use crate::llm::actions::StartupExamples;
407        use serde_json::json;
408
409        StartupExamples::new(
410            // LLM mode: LLM controls OIDC authentication flow
411            json!({
412                "type": "open_client",
413                "remote_addr": "https://accounts.google.com",
414                "base_stack": "openidconnect",
415                "instruction": "Discover configuration and start device code authentication",
416                "startup_params": {
417                    "client_id": "my-application-id",
418                    "scopes": "openid profile email"
419                }
420            }),
421            // Script mode: Code-based OIDC token handling
422            json!({
423                "type": "open_client",
424                "remote_addr": "https://accounts.google.com",
425                "base_stack": "openidconnect",
426                "startup_params": {
427                    "client_id": "my-application-id"
428                },
429                "event_handlers": [{
430                    "event_pattern": "oidc_token_received",
431                    "handler": {
432                        "type": "script",
433                        "language": "python",
434                        "code": "<oidc_client_handler>"
435                    }
436                }]
437            }),
438            // Static mode: Fixed OIDC discovery and userinfo flow
439            json!({
440                "type": "open_client",
441                "remote_addr": "https://accounts.google.com",
442                "base_stack": "openidconnect",
443                "startup_params": {
444                    "client_id": "my-application-id",
445                    "scopes": "openid profile email"
446                },
447                "event_handlers": [
448                    {
449                        "event_pattern": "oidc_discovered",
450                        "handler": {
451                            "type": "static",
452                            "actions": [{
453                                "type": "start_device_flow",
454                                "scopes": "openid profile email"
455                            }]
456                        }
457                    },
458                    {
459                        "event_pattern": "oidc_token_received",
460                        "handler": {
461                            "type": "static",
462                            "actions": [{
463                                "type": "fetch_userinfo"
464                            }]
465                        }
466                    },
467                    {
468                        "event_pattern": "oidc_userinfo_received",
469                        "handler": {
470                            "type": "static",
471                            "actions": [{
472                                "type": "disconnect"
473                            }]
474                        }
475                    }
476                ]
477            }),
478        )
479    }
480}
481
482// Implement Client trait (client-specific functionality)
483impl Client for OpenIdConnectClientProtocol {
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::openidconnect::OpenIdConnectClient;
492            OpenIdConnectClient::connect_with_llm_actions(
493                ctx.remote_addr,
494                ctx.llm_client,
495                ctx.state,
496                ctx.status_tx,
497                ctx.client_id,
498                ctx.startup_params,
499            )
500            .await
501        })
502    }
503    fn execute_action(&self, action: serde_json::Value) -> Result<ClientActionResult> {
504        let action_type = action
505            .get("type")
506            .and_then(|v| v.as_str())
507            .context("Missing 'type' field in action")?;
508
509        match action_type {
510            "discover_configuration" => Ok(ClientActionResult::Custom {
511                name: "oidc_discover".to_string(),
512                data: json!({}),
513            }),
514            "start_device_flow" => {
515                let scopes = action
516                    .get("scopes")
517                    .and_then(|v| v.as_str())
518                    .map(|s| s.to_string());
519
520                Ok(ClientActionResult::Custom {
521                    name: "oidc_device_flow".to_string(),
522                    data: json!({
523                        "scopes": scopes,
524                    }),
525                })
526            }
527            "start_authorization_code_flow" => {
528                let scopes = action
529                    .get("scopes")
530                    .and_then(|v| v.as_str())
531                    .map(|s| s.to_string());
532
533                // Range-check before narrowing. `as u16` wraps, so `66079` becomes `543` and
534                // the redirect listener binds a port the model never named — and `65536`
535                // becomes `0`, which is "pick any ephemeral port", so the redirect URI
536                // registered with the provider points somewhere nothing is listening.
537                let port = action.get("port").and_then(|v| v.as_u64()).unwrap_or(8080);
538                if !(1..=u16::MAX as u64).contains(&port) {
539                    return Err(anyhow::anyhow!(
540                        "port {port} is not a TCP port (1-65535). This is the local port the \
541                         authorization-code redirect is received on."
542                    ));
543                }
544                let port = port as u16;
545
546                Ok(ClientActionResult::Custom {
547                    name: "oidc_authorization_code".to_string(),
548                    data: json!({
549                        "scopes": scopes,
550                        "port": port,
551                    }),
552                })
553            }
554            "exchange_password" => {
555                let username = action
556                    .get("username")
557                    .and_then(|v| v.as_str())
558                    .context("Missing 'username' field")?
559                    .to_string();
560
561                let password = action
562                    .get("password")
563                    .and_then(|v| v.as_str())
564                    .context("Missing 'password' field")?
565                    .to_string();
566
567                let scopes = action
568                    .get("scopes")
569                    .and_then(|v| v.as_str())
570                    .map(|s| s.to_string());
571
572                Ok(ClientActionResult::Custom {
573                    name: "oidc_password_flow".to_string(),
574                    data: json!({
575                        "username": username,
576                        "password": password,
577                        "scopes": scopes,
578                    }),
579                })
580            }
581            "exchange_client_credentials" => {
582                let scopes = action
583                    .get("scopes")
584                    .and_then(|v| v.as_str())
585                    .map(|s| s.to_string());
586
587                Ok(ClientActionResult::Custom {
588                    name: "oidc_client_credentials".to_string(),
589                    data: json!({
590                        "scopes": scopes,
591                    }),
592                })
593            }
594            "refresh_token" => Ok(ClientActionResult::Custom {
595                name: "oidc_refresh_token".to_string(),
596                data: json!({}),
597            }),
598            "fetch_userinfo" => Ok(ClientActionResult::Custom {
599                name: "oidc_fetch_userinfo".to_string(),
600                data: json!({}),
601            }),
602            "disconnect" => Ok(ClientActionResult::Disconnect),
603            _ => Err(anyhow::anyhow!(
604                "Unknown OpenID Connect client action: {}",
605                action_type
606            )),
607        }
608    }
609}