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 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
57pub 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 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
111pub 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
136pub struct OpenIdConnectClientProtocol;
138
139impl OpenIdConnectClientProtocol {
140 pub fn new() -> Self {
141 Self
142 }
143}
144
145impl 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 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 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 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
482impl 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 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}