Skip to main content

netget/client/openidconnect/
mod.rs

1//! OpenID Connect client implementation
2pub mod actions;
3
4pub use actions::OpenIdConnectClientProtocol;
5
6use anyhow::{Context, Result};
7use std::net::SocketAddr;
8use std::sync::Arc;
9use tokio::sync::mpsc;
10use tracing::{error, info};
11use urlencoding;
12
13use crate::client::http_fetch::FetchClient;
14use crate::client::llm_budget::call_llm_for_client;
15use crate::client::openidconnect::actions::{
16    OIDC_CLIENT_DISCOVERED_EVENT, OIDC_CLIENT_TOKEN_RECEIVED_EVENT,
17    OIDC_CLIENT_USERINFO_RECEIVED_EVENT,
18};
19use crate::llm::actions::client_trait::ClientActionResult;
20use crate::llm::ollama_client::OllamaClient;
21use crate::llm::ClientLlmResult;
22use crate::logging::emit::Log;
23use crate::protocol::Event;
24use crate::state::app_state::AppState;
25use crate::state::client_handles::{ClientCommand, ClientSendOutcome};
26use crate::state::{AccessLogOwner, ClientId, ClientStatus};
27
28/// The HTTP function the `openidconnect` crate is handed for every request it makes. Natively it is
29/// the crate's own reqwest `async_http_client`, unchanged.
30#[cfg(not(target_arch = "wasm32"))]
31use openidconnect::reqwest::async_http_client as http_hook;
32
33/// The HTTP function the `openidconnect` crate is handed in the browser build. The crate's own
34/// `async_http_client` is reqwest's `fetch` backend there, which cannot reach the page's virtual
35/// loopback, so each request goes through the shared hyper transport
36/// ([`crate::client::http_fetch`]) instead: 30 seconds an exchange, 8 MiB of body, no redirects
37/// (the crate's own client follows none either), and `https://` refused with the reason.
38#[cfg(target_arch = "wasm32")]
39async fn http_hook(
40    request: openidconnect::HttpRequest,
41) -> std::result::Result<openidconnect::HttpResponse, crate::client::http_fetch::FetchError> {
42    let client = FetchClient::transport(std::time::Duration::from_secs(30));
43    let headers = request
44        .headers
45        .iter()
46        .map(|(name, value)| (name.as_str().to_string(), value.as_bytes().to_vec()))
47        .collect();
48    let (status, headers, body) = crate::client::http_fetch::round_trip_parts(
49        &client,
50        request.method.as_str(),
51        request.url.as_str(),
52        headers,
53        request.body,
54    )
55    .await?;
56    let mut map = openidconnect::http::HeaderMap::new();
57    for (name, value) in headers {
58        if let (Ok(name), Ok(value)) = (
59            openidconnect::http::header::HeaderName::from_bytes(name.as_bytes()),
60            openidconnect::http::HeaderValue::from_bytes(&value),
61        ) {
62            map.append(name, value);
63        }
64    }
65    let status_code = openidconnect::http::StatusCode::from_u16(status).map_err(|e| {
66        crate::client::http_fetch::FetchError(anyhow::anyhow!("status {status}: {e}"))
67    })?;
68    Ok(openidconnect::HttpResponse {
69        status_code,
70        headers: map,
71        body: body.to_vec(),
72    })
73}
74
75/// The HTTP client for the requests this client writes itself (device-code polling, the
76/// authorization-code callback's exchange): a fresh reqwest client natively, as before, and the
77/// shared hyper transport in the browser build, bounded at 30 seconds.
78fn plain_http_client() -> FetchClient {
79    #[cfg(not(target_arch = "wasm32"))]
80    let client = FetchClient::from_reqwest(reqwest::Client::new());
81    #[cfg(target_arch = "wasm32")]
82    let client = FetchClient::transport(std::time::Duration::from_secs(30));
83    client
84}
85
86use openidconnect::{
87    core::{CoreClient, CoreProviderMetadata, CoreTokenResponse, CoreUserInfoClaims},
88    ClientId as OidcClientId, ClientSecret, IssuerUrl, OAuth2TokenResponse, ResourceOwnerPassword,
89    ResourceOwnerUsername, Scope,
90};
91
92/// What [`OpenIdConnectClient::apply_action`] did with one executed action.
93///
94/// Every OIDC flow is HTTPS request/response through the `openidconnect` crate, so NetGet
95/// owns no socket whose byte count could be reported. The description is built from what is
96/// observably true afterwards - in particular whether a token was actually stored - so a
97/// flow that failed can never read as a success, and nothing here can invent a token.
98enum Applied {
99    /// The action ran; the string describes the effect.
100    Ran(String),
101    /// The action asked to end the session.
102    Disconnect,
103}
104
105/// Whether an event is reported to the LLM inline or from its own task.
106#[derive(Clone, Copy)]
107enum Dispatch {
108    /// Raise it here and now. Used by the LLM path and by the flows' own spawned tasks.
109    Inline,
110    /// Hand it to a registered task. Used by the injected-command loop, so a manual
111    /// (human-answered) routing rule on `oidc_token_received` / `oidc_userinfo_received`
112    /// cannot hold up the command's outcome, or the next injected command.
113    Deferred,
114}
115
116/// OpenID Connect client that handles OAuth2/OIDC authentication flows
117pub struct OpenIdConnectClient;
118
119impl OpenIdConnectClient {
120    /// Connect to an OpenID Connect provider with integrated LLM actions
121    pub async fn connect_with_llm_actions(
122        remote_addr: String,
123        llm_client: OllamaClient,
124        app_state: Arc<AppState>,
125        status_tx: mpsc::UnboundedSender<String>,
126        client_id: ClientId,
127        startup_params: Option<crate::protocol::StartupParams>,
128    ) -> Result<SocketAddr> {
129        info!(
130            "OpenID Connect client {} initializing for {}",
131            client_id, remote_addr
132        );
133
134        // `flow` names the OAuth2/OIDC flow to run, and nothing read it: the flow was
135        // whichever action the model happened to choose, so an operator who asked for
136        // `client_credentials` could get a device-code prompt instead. It is resolved here
137        // into the action that runs once discovery has completed.
138        //
139        // `password` is refused rather than silently ignored: it needs a username and a
140        // password, which are per-action parameters this client deliberately does not
141        // declare at startup, so there is no value of `flow` alone that could start it.
142        let (flow, startup_scopes) = match &startup_params {
143            Some(params) => (
144                params.get_optional_string("flow")?,
145                params.get_optional_string("scopes")?,
146            ),
147            None => (None, None),
148        };
149        let flow_action = match flow.as_deref() {
150            None => None,
151            Some("device_code") => Some(serde_json::json!({
152                "type": "start_device_flow", "scopes": startup_scopes })),
153            Some("authorization_code") => Some(serde_json::json!({
154                "type": "start_authorization_code_flow", "scopes": startup_scopes })),
155            Some("client_credentials") => Some(serde_json::json!({
156                "type": "exchange_client_credentials", "scopes": startup_scopes })),
157            Some("password") => {
158                return Err(anyhow::anyhow!(
159                    "OpenID Connect client: `flow: \"password\"` cannot be started from \
160                     startup parameters - the resource-owner password flow needs a username \
161                     and a password, and this client only accepts those on the \
162                     `exchange_password` action. Drive it with that action (the dashboard's \
163                     [ exchange_password ] row, or an instruction telling the model to use \
164                     it) and leave `flow` unset."
165                ));
166            }
167            Some(other) => {
168                return Err(anyhow::anyhow!(
169                    "OpenID Connect client: unknown `flow` value '{other}'. Expected one of \
170                     device_code, authorization_code, client_credentials (password is \
171                     action-driven; see the error for flow=\"password\")."
172                ));
173            }
174        };
175
176        // In the browser build the transport has no TLS: an `https://` provider is refused here,
177        // with the reason, rather than inside discovery.
178        crate::client::http_fetch::check_url(&remote_addr)?;
179
180        // Store provider URL in protocol_data, and seed the startup params alongside it.
181        // Every OIDC flow reads `client_id` / `client_secret` out of `protocol_data`, but
182        // the generic creation path (dashboard form, MCP, `open_client`) only stores the
183        // validated startup params on the client and leaves `protocol_data` empty - so a
184        // client created that way arrived here unconfigured.
185        app_state
186            .with_client_mut(client_id, |client| {
187                if let Some(serde_json::Value::Object(params)) = client.startup_params.clone() {
188                    for (key, value) in params {
189                        if client.get_protocol_field(&key).is_none() {
190                            client.set_protocol_field(key, value);
191                        }
192                    }
193                }
194                client
195                    .set_protocol_field("provider_url".to_string(), serde_json::json!(remote_addr));
196            })
197            .await;
198
199        // Update status
200        app_state
201            .update_client_status(client_id, ClientStatus::Connected)
202            .await;
203        Log::new(Some(&status_tx)).info(format!(
204            "OpenID Connect client {} ready for {}",
205            client_id, remote_addr
206        ));
207        let _ = status_tx.send("__UPDATE_UI__".to_string());
208
209        // Command channel for injected actions (the dashboard's [ exchange_password ],
210        // [ fetch_userinfo ], ...). Registered BEFORE the discovery + connected-event LLM
211        // call below, which is awaited inline and which a manual `*` routing rule can park
212        // for minutes - the operator must be able to drive the flow while it waits.
213        //
214        // This task also replaces the old "poll get_client() every 5s" idle task: the loop
215        // ends when the client is removed, because `remove_client` drops the sender.
216        let command_rx =
217            crate::client::command_support::register_command_channel(&app_state, client_id).await;
218        let cmd_task = tokio::spawn(Self::command_loop(
219            command_rx,
220            client_id,
221            app_state.clone(),
222            llm_client.clone(),
223            status_tx.clone(),
224        ));
225        app_state.register_client_task(client_id, cmd_task).await;
226
227        // Trigger initial discovery
228        if let Some(instruction) = app_state.get_instruction_for_client(client_id).await {
229            let protocol = Arc::new(OpenIdConnectClientProtocol::new());
230
231            // Auto-discover configuration
232            if let Err(e) = Self::discover_and_call_llm(
233                &remote_addr,
234                client_id,
235                &llm_client,
236                &app_state,
237                &status_tx,
238                &instruction,
239                protocol,
240            )
241            .await
242            {
243                Log::new(Some(&status_tx))
244                    .error(format!("Failed to discover OIDC configuration: {}", e));
245            }
246        }
247
248        // Start the flow the operator asked for.
249        //
250        // From its own registered task, not inline: the device flow's polling and the
251        // authorization-code flow's callback server both outlive `connect()`, which must
252        // return promptly. It runs through `execute_llm_action`, the same entry point the
253        // model's own flow actions use, so an operator-selected flow and a model-selected
254        // one are the same code path.
255        if let Some(action) = flow_action {
256            let flow_state = app_state.clone();
257            let flow_llm = llm_client.clone();
258            let flow_status = status_tx.clone();
259            let flow_task = tokio::spawn(async move {
260                let protocol = Arc::new(OpenIdConnectClientProtocol::new());
261                if let Err(e) = Self::execute_llm_action(
262                    action,
263                    client_id,
264                    &flow_llm,
265                    &flow_state,
266                    &flow_status,
267                    protocol,
268                )
269                .await
270                {
271                    Log::new(Some(&flow_status)).error(format!(
272                        "OpenID Connect client {}: the `flow` startup parameter's flow failed: {}",
273                        client_id, e
274                    ));
275                }
276            });
277            app_state.register_client_task(client_id, flow_task).await;
278        }
279
280        // Return a dummy local address (OIDC is HTTP-based, connectionless)
281        Ok("0.0.0.0:0".parse().unwrap())
282    }
283
284    /// Discover OpenID Connect provider configuration and call LLM
285    async fn discover_and_call_llm(
286        provider_url: &str,
287        client_id: ClientId,
288        llm_client: &OllamaClient,
289        app_state: &Arc<AppState>,
290        status_tx: &mpsc::UnboundedSender<String>,
291        instruction: &str,
292        protocol: Arc<OpenIdConnectClientProtocol>,
293    ) -> Result<()> {
294        info!("Discovering OIDC configuration for {}", provider_url);
295
296        // Discover provider metadata
297        let issuer_url = IssuerUrl::new(provider_url.to_string()).context("Invalid issuer URL")?;
298
299        let provider_metadata = CoreProviderMetadata::discover_async(issuer_url.clone(), http_hook)
300            .await
301            .context("Failed to discover OIDC provider metadata")?;
302
303        Log::new(Some(status_tx))
304            .info(format!("Discovered OIDC provider: {}", issuer_url.as_str()));
305
306        // Store provider metadata
307        app_state
308            .with_client_mut(client_id, |client| {
309                client.set_protocol_field(
310                "provider_metadata".to_string(),
311                serde_json::json!({
312                    "issuer": issuer_url.as_str(),
313                    "authorization_endpoint": provider_metadata.authorization_endpoint().as_str(),
314                    "token_endpoint": provider_metadata.token_endpoint().map(|u| u.as_str()),
315                    "userinfo_endpoint": provider_metadata.userinfo_endpoint().map(|u| u.as_str()),
316                }),
317            );
318            })
319            .await;
320
321        // Call LLM with discovered event
322        let event = Event::new(
323            &OIDC_CLIENT_DISCOVERED_EVENT,
324            serde_json::json!({
325                "issuer": issuer_url.as_str(),
326                "authorization_endpoint": provider_metadata.authorization_endpoint().as_str(),
327                "token_endpoint": provider_metadata.token_endpoint().map(|u| u.as_str()),
328                "userinfo_endpoint": provider_metadata.userinfo_endpoint().map(|u| u.as_str()),
329                "supported_scopes": provider_metadata.scopes_supported()
330                    .map(|scopes| scopes.iter().map(|s| s.as_str()).collect::<Vec<_>>()),
331            }),
332        );
333
334        let memory = app_state
335            .get_memory_for_client(client_id)
336            .await
337            .unwrap_or_default();
338
339        match call_llm_for_client(
340            llm_client,
341            app_state,
342            client_id.to_string(),
343            instruction,
344            &memory,
345            Some(&event),
346            protocol.as_ref(),
347            status_tx,
348        )
349        .await
350        {
351            Ok(ClientLlmResult {
352                actions,
353                memory_updates,
354            }) => {
355                // Update memory
356                if let Some(mem) = memory_updates {
357                    app_state.set_memory_for_client(client_id, mem).await;
358                }
359
360                // Execute actions
361                for action in actions {
362                    if let Err(e) = Self::execute_llm_action(
363                        action,
364                        client_id,
365                        llm_client,
366                        app_state,
367                        status_tx,
368                        protocol.clone(),
369                    )
370                    .await
371                    {
372                        Log::new(Some(status_tx))
373                            .error(format!("Failed to execute OIDC action: {}", e));
374                    }
375                }
376            }
377            Err(e) => {
378                error!("LLM error for OIDC client {}: {}", client_id, e);
379            }
380        }
381
382        Ok(())
383    }
384
385    /// Execute an LLM-generated action: the model's path into [`Self::apply_action`].
386    ///
387    /// Kept as a separate entry point because the follow-up actions the model returns from
388    /// a token/userinfo event come back here, and that recursion needs a boxed future.
389    fn execute_llm_action<'a>(
390        action: serde_json::Value,
391        client_id: ClientId,
392        llm_client: &'a OllamaClient,
393        app_state: &'a Arc<AppState>,
394        status_tx: &'a mpsc::UnboundedSender<String>,
395        protocol: Arc<OpenIdConnectClientProtocol>,
396    ) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<()>> + Send + 'a>> {
397        Box::pin(async move {
398            use crate::llm::actions::Client;
399
400            let result = protocol.execute_action(action.clone())?;
401            match Self::apply_action(
402                result,
403                Dispatch::Inline,
404                client_id,
405                llm_client,
406                app_state,
407                status_tx,
408                protocol,
409            )
410            .await?
411            {
412                Applied::Ran(detail) => info!("OIDC client {}: {}", client_id, detail),
413                Applied::Disconnect => {
414                    // The model asking to disconnect tears the client down, as it always
415                    // has. An *injected* disconnect only marks it Disconnected - removing
416                    // the client from under the command loop would abort the very task
417                    // that owes the dashboard a reply.
418                    app_state.remove_client(client_id).await;
419                }
420            }
421            Ok(())
422        })
423    }
424
425    /// Run one executed action. Shared by the LLM path and injected commands, so a flow is
426    /// driven by exactly one implementation whoever asked for it.
427    #[allow(clippy::too_many_arguments)]
428    fn apply_action<'a>(
429        result: ClientActionResult,
430        dispatch: Dispatch,
431        client_id: ClientId,
432        llm_client: &'a OllamaClient,
433        app_state: &'a Arc<AppState>,
434        status_tx: &'a mpsc::UnboundedSender<String>,
435        protocol: Arc<OpenIdConnectClientProtocol>,
436    ) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<Applied>> + Send + 'a>> {
437        Box::pin(async move {
438            match result {
439                ClientActionResult::Custom { name, data } => match name.as_str() {
440                    "oidc_discover" => {
441                        let provider_url = app_state
442                            .with_client_mut(client_id, |client| {
443                                client
444                                    .get_protocol_field("provider_url")
445                                    .and_then(|v| v.as_str())
446                                    .map(|s| s.to_string())
447                            })
448                            .await
449                            .flatten()
450                            .context("No provider URL found")?;
451                        let instruction = app_state
452                            .get_instruction_for_client(client_id)
453                            .await
454                            .unwrap_or_default();
455                        Self::discover_and_call_llm(
456                            &provider_url,
457                            client_id,
458                            llm_client,
459                            app_state,
460                            status_tx,
461                            &instruction,
462                            protocol,
463                        )
464                        .await?;
465                        Ok(Applied::Ran(format!(
466                            "oidc_discover: provider metadata fetched from {provider_url}"
467                        )))
468                    }
469                    "oidc_device_flow" => {
470                        Self::start_device_flow(
471                            client_id, data, llm_client, app_state, status_tx, protocol,
472                        )
473                        .await?;
474                        Ok(Applied::Ran(
475                            "oidc_device_flow: device code issued, polling started".to_string(),
476                        ))
477                    }
478                    "oidc_authorization_code" => {
479                        Self::start_authorization_code_flow(
480                            client_id, data, llm_client, app_state, status_tx, protocol,
481                        )
482                        .await?;
483                        Ok(Applied::Ran(
484                            "oidc_authorization_code: authorization URL built, callback server \
485                             waiting"
486                                .to_string(),
487                        ))
488                    }
489                    "oidc_password_flow" => {
490                        Self::exchange_password(
491                            client_id, data, llm_client, app_state, status_tx, protocol, dispatch,
492                        )
493                        .await?;
494                        Ok(Applied::Ran(
495                            Self::token_state_detail("oidc_password_flow", client_id, app_state)
496                                .await,
497                        ))
498                    }
499                    "oidc_client_credentials" => {
500                        Self::exchange_client_credentials(
501                            client_id, data, llm_client, app_state, status_tx, protocol, dispatch,
502                        )
503                        .await?;
504                        Ok(Applied::Ran(
505                            Self::token_state_detail(
506                                "oidc_client_credentials",
507                                client_id,
508                                app_state,
509                            )
510                            .await,
511                        ))
512                    }
513                    "oidc_refresh_token" => {
514                        Self::refresh_token(
515                            client_id, llm_client, app_state, status_tx, protocol, dispatch,
516                        )
517                        .await?;
518                        Ok(Applied::Ran(
519                            Self::token_state_detail("oidc_refresh_token", client_id, app_state)
520                                .await,
521                        ))
522                    }
523                    "oidc_fetch_userinfo" => {
524                        Self::fetch_userinfo(
525                            client_id, llm_client, app_state, status_tx, protocol, dispatch,
526                        )
527                        .await?;
528                        Ok(Applied::Ran(
529                            "oidc_fetch_userinfo: UserInfo endpoint answered".to_string(),
530                        ))
531                    }
532                    other => Err(anyhow::anyhow!("Unknown OIDC action: {}", other)),
533                },
534                ClientActionResult::Disconnect => {
535                    info!("OIDC client {} disconnecting", client_id);
536                    app_state
537                        .update_client_status(client_id, ClientStatus::Disconnected)
538                        .await;
539                    let _ = status_tx.send("__UPDATE_UI__".to_string());
540                    Ok(Applied::Disconnect)
541                }
542                _ => Err(anyhow::anyhow!("Unsupported action result type")),
543            }
544        })
545    }
546
547    /// Describe a token flow by what it left behind - never by assuming it worked.
548    async fn token_state_detail(
549        name: &str,
550        client_id: ClientId,
551        app_state: &Arc<AppState>,
552    ) -> String {
553        let has_token = app_state
554            .with_client_mut(client_id, |client| {
555                client.get_protocol_field("access_token").is_some()
556            })
557            .await
558            .unwrap_or(false);
559        if has_token {
560            format!("{name}: completed, an access token is stored")
561        } else {
562            format!(
563                "{name}: completed but no access token was stored - the provider did not \
564                 issue one (see netget.log)"
565            )
566        }
567    }
568
569    /// Drain injected commands until the channel closes (the client was removed) or an
570    /// injected `disconnect` ends the session.
571    ///
572    /// The flow itself is awaited, so the reported [`ClientSendOutcome`] describes what the
573    /// provider actually did; only the resulting token/userinfo event is handed to its own
574    /// task, so a manual routing rule waiting on a human cannot hold up the reply.
575    async fn command_loop(
576        mut command_rx: mpsc::Receiver<ClientCommand>,
577        client_id: ClientId,
578        app_state: Arc<AppState>,
579        llm_client: OllamaClient,
580        status_tx: mpsc::UnboundedSender<String>,
581    ) {
582        use crate::llm::actions::protocol_trait::Protocol;
583        use crate::llm::actions::Client;
584
585        let protocol = Arc::new(OpenIdConnectClientProtocol::new());
586
587        while let Some(command) = command_rx.recv().await {
588            let action = command.action.clone();
589            let outcome = match protocol.execute_action(action.clone()) {
590                Err(e) => Ok(ClientSendOutcome::Rejected {
591                    error: e.to_string(),
592                }),
593                Ok(result) => Self::apply_action(
594                    result,
595                    Dispatch::Deferred,
596                    client_id,
597                    &llm_client,
598                    &app_state,
599                    &status_tx,
600                    protocol.clone(),
601                )
602                .await
603                .map(|applied| match applied {
604                    Applied::Disconnect => ClientSendOutcome::Disconnected,
605                    Applied::Ran(detail) => ClientSendOutcome::Executed { detail },
606                }),
607            };
608
609            let outcome_json = match &outcome {
610                Ok(outcome) => serde_json::to_value(outcome).unwrap_or(serde_json::Value::Null),
611                Err(e) => serde_json::json!({"error": e.to_string()}),
612            };
613            app_state
614                .record_access_log(
615                    AccessLogOwner::Client(client_id.as_u32()),
616                    protocol.protocol_name(),
617                    None,
618                    "injected_action",
619                    action,
620                    vec![outcome_json],
621                )
622                .await;
623
624            let disconnect = matches!(outcome, Ok(ClientSendOutcome::Disconnected));
625            if let Err(e) = &outcome {
626                error!("OIDC client {} injected action failed: {}", client_id, e);
627                let _ = status_tx.send(format!(
628                    "[WARN] Client {} injected action failed: {}",
629                    client_id, e
630                ));
631            }
632            let _ = status_tx.send("__UPDATE_UI__".to_string());
633            crate::client::command_support::reply(command, outcome);
634
635            if disconnect {
636                break;
637            }
638        }
639
640        app_state.remove_client_handle(client_id).await;
641        let _ = status_tx.send("__UPDATE_UI__".to_string());
642        info!("OIDC client {} command loop ended", client_id);
643    }
644
645    /// Start device code flow (RFC 8628)
646    async fn start_device_flow(
647        client_id: ClientId,
648        data: serde_json::Value,
649        llm_client: &OllamaClient,
650        app_state: &Arc<AppState>,
651        status_tx: &mpsc::UnboundedSender<String>,
652        protocol: Arc<OpenIdConnectClientProtocol>,
653    ) -> Result<()> {
654        Log::new(Some(status_tx)).info(format!(
655            "Starting device code flow for client {}",
656            client_id
657        ));
658
659        // Get provider metadata and client config
660        let (provider_url, oidc_client_id, oidc_client_secret) = app_state
661            .with_client_mut(client_id, |client| {
662                let provider = client
663                    .get_protocol_field("provider_url")
664                    .and_then(|v| v.as_str())
665                    .map(|s| s.to_string())?;
666                let client_id_str = client
667                    .get_protocol_field("client_id")
668                    .and_then(|v| v.as_str())
669                    .map(|s| s.to_string())
670                    .unwrap_or_else(|| "default-client-id".to_string());
671                let client_secret_str = client
672                    .get_protocol_field("client_secret")
673                    .and_then(|v| v.as_str())
674                    .map(|s| s.to_string());
675                Some((provider, client_id_str, client_secret_str))
676            })
677            .await
678            .flatten()
679            .context("No provider URL found")?;
680
681        let scopes = data
682            .get("scopes")
683            .and_then(|v| v.as_str())
684            .unwrap_or("openid");
685
686        let issuer_url = IssuerUrl::new(provider_url)?;
687        let provider_metadata =
688            CoreProviderMetadata::discover_async(issuer_url.clone(), http_hook).await?;
689
690        // Construct device authorization endpoint URL (typically /device/code or /device/authorize)
691        let device_auth_url = format!("{}/device/code", issuer_url.as_str().trim_end_matches('/'));
692        Log::new(Some(status_tx)).info(format!(
693            "Device authorization endpoint: {}",
694            device_auth_url
695        ));
696
697        // Build request body
698        let mut params = vec![("client_id", oidc_client_id.as_str()), ("scope", scopes)];
699
700        if let Some(ref secret) = oidc_client_secret {
701            params.push(("client_secret", secret.as_str()));
702        }
703
704        // Make device authorization request
705        let http_client = plain_http_client();
706        let response = http_client
707            .post(&device_auth_url)
708            .form(&params)
709            .send()
710            .await
711            .context("Failed to send device authorization request")?;
712
713        if !response.status().is_success() {
714            let error_text = response
715                .text()
716                .await
717                .unwrap_or_else(|_| "Unknown error".to_string());
718            return Err(anyhow::anyhow!(
719                "Device authorization failed: {}",
720                error_text
721            ));
722        }
723
724        let device_response: serde_json::Value = response
725            .json()
726            .await
727            .context("Failed to parse device authorization response")?;
728
729        // Extract device code response fields
730        let device_code = device_response
731            .get("device_code")
732            .and_then(|v| v.as_str())
733            .context("Missing device_code in response")?
734            .to_string();
735
736        let user_code = device_response
737            .get("user_code")
738            .and_then(|v| v.as_str())
739            .context("Missing user_code in response")?
740            .to_string();
741
742        let verification_uri = device_response
743            .get("verification_uri")
744            .and_then(|v| v.as_str())
745            .context("Missing verification_uri in response")?
746            .to_string();
747
748        let verification_uri_complete = device_response
749            .get("verification_uri_complete")
750            .and_then(|v| v.as_str())
751            .map(|s| s.to_string());
752
753        let interval = device_response
754            .get("interval")
755            .and_then(|v| v.as_u64())
756            .unwrap_or(5);
757
758        let expires_in = device_response
759            .get("expires_in")
760            .and_then(|v| v.as_u64())
761            .unwrap_or(300);
762
763        // Display device code and verification URL to user
764        let log = Log::new(Some(status_tx));
765        let _ = status_tx.send("========================================".to_string());
766        log.info("Device Code Flow - User Action Required".to_string());
767        let _ = status_tx.send("========================================".to_string());
768        log.info("1. Open this URL in your browser:".to_string());
769        log.info(format!("   {}", verification_uri));
770        if let Some(complete_uri) = verification_uri_complete {
771            log.info(format!("   Or use this direct link: {}", complete_uri));
772        }
773        log.info(format!("2. Enter this code: {}", user_code));
774        let _ = status_tx.send("========================================".to_string());
775        log.info("Waiting for authorization...".to_string());
776
777        // Get token endpoint
778        let token_endpoint = provider_metadata
779            .token_endpoint()
780            .context("No token endpoint in provider metadata")?
781            .as_str()
782            .to_string();
783
784        // Spawn polling task
785        let app_state_clone = app_state.clone();
786        let llm_client_clone = llm_client.clone();
787        let status_tx_clone = status_tx.clone();
788        let protocol_clone = protocol.clone();
789        let oidc_client_id_clone = oidc_client_id.clone();
790        let oidc_client_secret_clone = oidc_client_secret.clone();
791
792        // Registered with AppState so stop_client can abort this task —
793        // dropping a JoinHandle only detaches it in Tokio.
794        let task_registrar = app_state.clone();
795        let task_handle = tokio::spawn(async move {
796            let start_time = crate::utils::clock::Instant::now();
797            let mut poll_count = 0;
798            let interval_duration = std::time::Duration::from_secs(interval);
799            let expires_duration = std::time::Duration::from_secs(expires_in);
800            let log = Log::new(Some(&status_tx_clone));
801
802            loop {
803                // Check if expired
804                if start_time.elapsed() > expires_duration {
805                    log.error("Device code expired. Please try again.".to_string());
806                    break;
807                }
808
809                // Wait for interval
810                tokio::time::sleep(interval_duration).await;
811                poll_count += 1;
812
813                log.info(format!(
814                    "Polling for authorization (attempt {})...",
815                    poll_count
816                ));
817
818                // Build token request
819                let mut token_params = vec![
820                    ("grant_type", "urn:ietf:params:oauth:grant-type:device_code"),
821                    ("device_code", device_code.as_str()),
822                    ("client_id", oidc_client_id_clone.as_str()),
823                ];
824
825                if let Some(ref secret) = oidc_client_secret_clone {
826                    token_params.push(("client_secret", secret.as_str()));
827                }
828
829                // Poll token endpoint
830                let http_client = plain_http_client();
831                match http_client
832                    .post(&token_endpoint)
833                    .form(&token_params)
834                    .send()
835                    .await
836                {
837                    Ok(response) => {
838                        if response.status().is_success() {
839                            // Success - parse tokens
840                            match response.json::<serde_json::Value>().await {
841                                Ok(token_json) => {
842                                    log.info(
843                                        "Authorization successful! Received tokens.".to_string(),
844                                    );
845
846                                    // Convert JSON to CoreTokenResponse manually
847                                    if let Err(e) = Self::store_tokens_from_json(
848                                        client_id,
849                                        &token_json,
850                                        &llm_client_clone,
851                                        &app_state_clone,
852                                        &status_tx_clone,
853                                        protocol_clone,
854                                    )
855                                    .await
856                                    {
857                                        log.error(format!("Failed to store tokens: {}", e));
858                                    }
859                                    break;
860                                }
861                                Err(e) => {
862                                    log.error(format!("Failed to parse token response: {}", e));
863                                    break;
864                                }
865                            }
866                        } else {
867                            // Check error response
868                            match response.json::<serde_json::Value>().await {
869                                Ok(error_json) => {
870                                    let error_code = error_json
871                                        .get("error")
872                                        .and_then(|v| v.as_str())
873                                        .unwrap_or("unknown");
874
875                                    match error_code {
876                                        "authorization_pending" => {
877                                            // User hasn't authorized yet, continue polling
878                                            continue;
879                                        }
880                                        "slow_down" => {
881                                            // Slow down polling
882                                            log.info("Slowing down polling rate...".to_string());
883                                            tokio::time::sleep(interval_duration).await;
884                                            continue;
885                                        }
886                                        "expired_token" => {
887                                            log.error("Device code expired.".to_string());
888                                            break;
889                                        }
890                                        "access_denied" => {
891                                            log.error("User denied authorization.".to_string());
892                                            break;
893                                        }
894                                        _ => {
895                                            log.error(format!(
896                                                "Authorization error: {}",
897                                                error_code
898                                            ));
899                                            break;
900                                        }
901                                    }
902                                }
903                                Err(_) => {
904                                    log.error("Failed to parse error response".to_string());
905                                    break;
906                                }
907                            }
908                        }
909                    }
910                    Err(e) => {
911                        log.error(format!("Polling failed: {}", e));
912                        break;
913                    }
914                }
915            }
916        });
917        task_registrar
918            .register_client_task(client_id, task_handle)
919            .await;
920
921        Ok(())
922    }
923
924    /// Store tokens from JSON response (helper for device code flow)
925    async fn store_tokens_from_json(
926        client_id: ClientId,
927        token_json: &serde_json::Value,
928        llm_client: &OllamaClient,
929        app_state: &Arc<AppState>,
930        status_tx: &mpsc::UnboundedSender<String>,
931        protocol: Arc<OpenIdConnectClientProtocol>,
932    ) -> Result<()> {
933        let access_token = token_json
934            .get("access_token")
935            .and_then(|v| v.as_str())
936            .context("Missing access_token")?;
937        let id_token = token_json
938            .get("id_token")
939            .and_then(|v| v.as_str())
940            .map(|s| s.to_string());
941        let refresh_token = token_json
942            .get("refresh_token")
943            .and_then(|v| v.as_str())
944            .map(|s| s.to_string());
945        let expires_in = token_json.get("expires_in").and_then(|v| v.as_u64());
946        let token_type = token_json
947            .get("token_type")
948            .and_then(|v| v.as_str())
949            .unwrap_or("Bearer");
950
951        Log::new(Some(status_tx)).info(format!("Received tokens (expires_in: {:?}s)", expires_in));
952
953        // Store tokens
954        app_state
955            .with_client_mut(client_id, |client| {
956                client.set_protocol_field(
957                    "access_token".to_string(),
958                    serde_json::json!(access_token),
959                );
960                if let Some(id) = &id_token {
961                    client.set_protocol_field("id_token".to_string(), serde_json::json!(id));
962                }
963                if let Some(refresh) = &refresh_token {
964                    client.set_protocol_field(
965                        "refresh_token".to_string(),
966                        serde_json::json!(refresh),
967                    );
968                }
969            })
970            .await;
971
972        // Call LLM with token received event. This helper is only reached from the device
973        // and authorization-code flows' own spawned tasks, which are already off any
974        // command loop's critical path, so the event is raised inline there.
975        // The tokens are stored above and every action that needs one
976        // (`fetch_userinfo`, `refresh_token`, `validate_token`) reads it back out of
977        // `protocol_data`. The model therefore never needs the secret itself, and putting it
978        // in the event copied a live bearer token, a refresh token and an id_token into the
979        // LLM prompt — which goes to whatever backend is configured, is written to
980        // `netget.log`, and is echoed onto the status stream. The sibling `oauth2` client has
981        // always redacted these; this one did not. Report presence and metadata instead.
982        let event = Event::new(
983            &OIDC_CLIENT_TOKEN_RECEIVED_EVENT,
984            serde_json::json!({
985                "access_token": "[REDACTED]",
986                "id_token": if id_token.is_some() { "[REDACTED]" } else { "" },
987                "refresh_token": if refresh_token.is_some() { "[REDACTED]" } else { "" },
988                "expires_in": expires_in,
989                "token_type": token_type,
990            }),
991        );
992        Self::notify_event(
993            client_id,
994            event,
995            Dispatch::Inline,
996            llm_client,
997            app_state,
998            status_tx,
999            protocol,
1000        )
1001        .await;
1002
1003        Ok(())
1004    }
1005
1006    /// Start authorization code flow with local callback server
1007    async fn start_authorization_code_flow(
1008        client_id: ClientId,
1009        data: serde_json::Value,
1010        llm_client: &OllamaClient,
1011        app_state: &Arc<AppState>,
1012        status_tx: &mpsc::UnboundedSender<String>,
1013        protocol: Arc<OpenIdConnectClientProtocol>,
1014    ) -> Result<()> {
1015        use tokio::io::{AsyncReadExt, AsyncWriteExt};
1016        use tokio::net::TcpListener;
1017
1018        Log::new(Some(status_tx)).info(format!(
1019            "Starting authorization code flow for client {}",
1020            client_id
1021        ));
1022
1023        // Get provider metadata and client config
1024        let (provider_url, oidc_client_id, oidc_client_secret) = app_state
1025            .with_client_mut(client_id, |client| {
1026                let provider = client
1027                    .get_protocol_field("provider_url")
1028                    .and_then(|v| v.as_str())
1029                    .map(|s| s.to_string())?;
1030                let client_id_str = client
1031                    .get_protocol_field("client_id")
1032                    .and_then(|v| v.as_str())
1033                    .map(|s| s.to_string())
1034                    .unwrap_or_else(|| "default-client-id".to_string());
1035                let client_secret_str = client
1036                    .get_protocol_field("client_secret")
1037                    .and_then(|v| v.as_str())
1038                    .map(|s| s.to_string());
1039                Some((provider, client_id_str, client_secret_str))
1040            })
1041            .await
1042            .flatten()
1043            .context("No provider URL found")?;
1044
1045        let scopes = data
1046            .get("scopes")
1047            .and_then(|v| v.as_str())
1048            .unwrap_or("openid profile email");
1049
1050        let callback_port = crate::client::wire_values::number::<u16>(&data, "port", 8080)?;
1051
1052        let issuer_url = IssuerUrl::new(provider_url)?;
1053        let provider_metadata = CoreProviderMetadata::discover_async(issuer_url, http_hook).await?;
1054
1055        // Get authorization endpoint
1056        let auth_endpoint = provider_metadata.authorization_endpoint().as_str();
1057
1058        // Generate state for CSRF protection
1059        use rand::Rng;
1060        let state: String = rand::thread_rng()
1061            .sample_iter(&rand::distributions::Alphanumeric)
1062            .take(32)
1063            .map(char::from)
1064            .collect();
1065
1066        // Build redirect URI
1067        let redirect_uri = format!("http://localhost:{}/callback", callback_port);
1068
1069        // Build authorization URL
1070        let auth_url = format!(
1071            "{}?response_type=code&client_id={}&redirect_uri={}&scope={}&state={}",
1072            auth_endpoint,
1073            urlencoding::encode(&oidc_client_id),
1074            urlencoding::encode(&redirect_uri),
1075            urlencoding::encode(scopes),
1076            urlencoding::encode(&state)
1077        );
1078
1079        // Display authorization URL
1080        let log = Log::new(Some(status_tx));
1081        let _ = status_tx.send("========================================".to_string());
1082        log.info("Authorization Code Flow - User Action Required".to_string());
1083        let _ = status_tx.send("========================================".to_string());
1084        log.info("1. Open this URL in your browser:".to_string());
1085        log.info(format!("   {}", auth_url));
1086        log.info(format!(
1087            "2. After authorization, the browser will redirect to localhost:{}",
1088            callback_port
1089        ));
1090        let _ = status_tx.send("========================================".to_string());
1091        log.info(format!(
1092            "Starting local callback server on port {}...",
1093            callback_port
1094        ));
1095
1096        // Start local HTTP server
1097        let listener = TcpListener::bind(format!("127.0.0.1:{}", callback_port))
1098            .await
1099            .context(format!(
1100                "Failed to bind to port {}. Port may be in use.",
1101                callback_port
1102            ))?;
1103
1104        log.info(format!(
1105            "Callback server listening on http://127.0.0.1:{}/callback",
1106            callback_port
1107        ));
1108        log.info("Waiting for authorization...".to_string());
1109
1110        // Get token endpoint
1111        let token_endpoint = provider_metadata
1112            .token_endpoint()
1113            .context("No token endpoint in provider metadata")?
1114            .as_str()
1115            .to_string();
1116
1117        // Spawn server task
1118        let app_state_clone = app_state.clone();
1119        let llm_client_clone = llm_client.clone();
1120        let status_tx_clone = status_tx.clone();
1121        let protocol_clone = protocol.clone();
1122        let state_clone = state.clone();
1123
1124        // Registered with AppState so stop_client can abort this task —
1125        // dropping a JoinHandle only detaches it in Tokio.
1126        let task_registrar = app_state.clone();
1127        let task_handle = tokio::spawn(async move {
1128            let log = Log::new(Some(&status_tx_clone));
1129            // Accept one connection
1130            match listener.accept().await {
1131                Ok((mut socket, _addr)) => {
1132                    log.info("Received callback request...".to_string());
1133
1134                    // Read HTTP request
1135                    let mut buffer = vec![0u8; 4096];
1136                    match socket.read(&mut buffer).await {
1137                        Ok(n) => {
1138                            let request = String::from_utf8_lossy(&buffer[..n]);
1139
1140                            // Parse query parameters
1141                            if let Some(query_line) = request.lines().next() {
1142                                if let Some(query_str) = query_line.split_whitespace().nth(1) {
1143                                    if let Some(query_params) = query_str.split('?').nth(1) {
1144                                        let mut code = None;
1145                                        let mut returned_state = None;
1146                                        let mut error = None;
1147
1148                                        for param in query_params.split('&') {
1149                                            let parts: Vec<&str> = param.split('=').collect();
1150                                            if parts.len() == 2 {
1151                                                match parts[0] {
1152                                                    "code" => code = Some(parts[1].to_string()),
1153                                                    "state" => {
1154                                                        returned_state = Some(parts[1].to_string())
1155                                                    }
1156                                                    "error" => error = Some(parts[1].to_string()),
1157                                                    _ => {}
1158                                                }
1159                                            }
1160                                        }
1161
1162                                        // Send response to browser
1163                                        let response = if error.is_some() {
1164                                            "HTTP/1.1 400 Bad Request\r\nContent-Type: text/html\r\n\r\n<html><body><h1>Authorization Failed</h1><p>An error occurred during authorization. You can close this window.</p></body></html>"
1165                                        } else if code.is_some() {
1166                                            "HTTP/1.1 200 OK\r\nContent-Type: text/html\r\n\r\n<html><body><h1>Authorization Successful!</h1><p>You can close this window and return to the terminal.</p></body></html>"
1167                                        } else {
1168                                            "HTTP/1.1 400 Bad Request\r\nContent-Type: text/html\r\n\r\n<html><body><h1>Invalid Request</h1><p>Missing authorization code. You can close this window.</p></body></html>"
1169                                        };
1170
1171                                        let _ = socket.write_all(response.as_bytes()).await;
1172
1173                                        // Process authorization code
1174                                        if let Some(error_msg) = error {
1175                                            log.error(format!(
1176                                                "Authorization failed: {}",
1177                                                error_msg
1178                                            ));
1179                                            return;
1180                                        }
1181
1182                                        if let Some(auth_code) = code {
1183                                            // Verify state
1184                                            if returned_state.as_deref()
1185                                                != Some(state_clone.as_str())
1186                                            {
1187                                                log.error(
1188                                                    "State mismatch - possible CSRF attack"
1189                                                        .to_string(),
1190                                                );
1191                                                return;
1192                                            }
1193
1194                                            log.info("Authorization code received, exchanging for tokens...".to_string());
1195
1196                                            // Exchange authorization code for tokens
1197                                            let mut token_params = vec![
1198                                                ("grant_type", "authorization_code"),
1199                                                ("code", auth_code.as_str()),
1200                                                ("redirect_uri", redirect_uri.as_str()),
1201                                                ("client_id", oidc_client_id.as_str()),
1202                                            ];
1203
1204                                            if let Some(ref secret) = oidc_client_secret {
1205                                                token_params
1206                                                    .push(("client_secret", secret.as_str()));
1207                                            }
1208
1209                                            let http_client = plain_http_client();
1210                                            match http_client
1211                                                .post(&token_endpoint)
1212                                                .form(&token_params)
1213                                                .send()
1214                                                .await
1215                                            {
1216                                                Ok(response) => {
1217                                                    if response.status().is_success() {
1218                                                        match response
1219                                                            .json::<serde_json::Value>()
1220                                                            .await
1221                                                        {
1222                                                            Ok(token_json) => {
1223                                                                log.info("Successfully exchanged code for tokens!".to_string());
1224
1225                                                                if let Err(e) =
1226                                                                    Self::store_tokens_from_json(
1227                                                                        client_id,
1228                                                                        &token_json,
1229                                                                        &llm_client_clone,
1230                                                                        &app_state_clone,
1231                                                                        &status_tx_clone,
1232                                                                        protocol_clone,
1233                                                                    )
1234                                                                    .await
1235                                                                {
1236                                                                    log.error(format!("Failed to store tokens: {}", e));
1237                                                                }
1238                                                            }
1239                                                            Err(e) => {
1240                                                                log.error(format!("Failed to parse token response: {}", e));
1241                                                            }
1242                                                        }
1243                                                    } else {
1244                                                        let error_text =
1245                                                            response.text().await.unwrap_or_else(
1246                                                                |_| "Unknown error".to_string(),
1247                                                            );
1248                                                        log.error(format!(
1249                                                            "Token exchange failed: {}",
1250                                                            error_text
1251                                                        ));
1252                                                    }
1253                                                }
1254                                                Err(e) => {
1255                                                    log.error(format!(
1256                                                        "Failed to exchange code: {}",
1257                                                        e
1258                                                    ));
1259                                                }
1260                                            }
1261                                        }
1262                                    }
1263                                }
1264                            }
1265                        }
1266                        Err(e) => {
1267                            log.error(format!("Failed to read request: {}", e));
1268                        }
1269                    }
1270                }
1271                Err(e) => {
1272                    log.error(format!("Failed to accept connection: {}", e));
1273                }
1274            }
1275        });
1276        task_registrar
1277            .register_client_task(client_id, task_handle)
1278            .await;
1279
1280        Ok(())
1281    }
1282
1283    /// Exchange username/password for tokens
1284    #[allow(clippy::too_many_arguments)]
1285    async fn exchange_password(
1286        client_id: ClientId,
1287        data: serde_json::Value,
1288        llm_client: &OllamaClient,
1289        app_state: &Arc<AppState>,
1290        status_tx: &mpsc::UnboundedSender<String>,
1291        protocol: Arc<OpenIdConnectClientProtocol>,
1292        dispatch: Dispatch,
1293    ) -> Result<()> {
1294        let username = data
1295            .get("username")
1296            .and_then(|v| v.as_str())
1297            .context("Missing username")?;
1298        let password = data
1299            .get("password")
1300            .and_then(|v| v.as_str())
1301            .context("Missing password")?;
1302        let scopes = data
1303            .get("scopes")
1304            .and_then(|v| v.as_str())
1305            .unwrap_or("openid");
1306
1307        Log::new(Some(status_tx)).info(format!(
1308            "Exchanging password for tokens (user: {})",
1309            username
1310        ));
1311
1312        // Get client config and provider metadata
1313        let (oidc_client_id, oidc_client_secret, provider_url) = app_state
1314            .with_client_mut(client_id, |client| {
1315                let client_id_str = client
1316                    .get_protocol_field("client_id")
1317                    .and_then(|v| v.as_str())
1318                    .map(|s| s.to_string())
1319                    .unwrap_or_else(|| "default-client-id".to_string());
1320                let client_secret_str = client
1321                    .get_protocol_field("client_secret")
1322                    .and_then(|v| v.as_str())
1323                    .map(|s| s.to_string());
1324                let provider = client
1325                    .get_protocol_field("provider_url")
1326                    .and_then(|v| v.as_str())
1327                    .map(|s| s.to_string())
1328                    .unwrap_or_default();
1329                (client_id_str, client_secret_str, provider)
1330            })
1331            .await
1332            .unwrap_or_else(|| ("default-client-id".to_string(), None, String::new()));
1333
1334        let issuer_url = IssuerUrl::new(provider_url)?;
1335        let provider_metadata = CoreProviderMetadata::discover_async(issuer_url, http_hook).await?;
1336
1337        let client = if let Some(secret) = oidc_client_secret {
1338            CoreClient::from_provider_metadata(
1339                provider_metadata,
1340                OidcClientId::new(oidc_client_id),
1341                Some(ClientSecret::new(secret)),
1342            )
1343        } else {
1344            CoreClient::from_provider_metadata(
1345                provider_metadata,
1346                OidcClientId::new(oidc_client_id),
1347                None,
1348            )
1349        };
1350
1351        // Exchange password for tokens
1352        let username_param = ResourceOwnerUsername::new(username.to_string());
1353        let password_param = ResourceOwnerPassword::new(password.to_string());
1354        let mut token_request = client.exchange_password(&username_param, &password_param);
1355
1356        // Add scopes
1357        for scope in scopes.split_whitespace() {
1358            token_request = token_request.add_scope(Scope::new(scope.to_string()));
1359        }
1360
1361        let token_response = token_request
1362            .request_async(http_hook)
1363            .await
1364            .context("Failed to exchange password for tokens")?;
1365
1366        // Store tokens
1367        Self::store_and_notify_tokens(
1368            client_id,
1369            &token_response,
1370            llm_client,
1371            app_state,
1372            status_tx,
1373            protocol,
1374            dispatch,
1375        )
1376        .await?;
1377
1378        Ok(())
1379    }
1380
1381    /// Exchange client credentials for access token
1382    #[allow(clippy::too_many_arguments)]
1383    async fn exchange_client_credentials(
1384        client_id: ClientId,
1385        data: serde_json::Value,
1386        llm_client: &OllamaClient,
1387        app_state: &Arc<AppState>,
1388        status_tx: &mpsc::UnboundedSender<String>,
1389        protocol: Arc<OpenIdConnectClientProtocol>,
1390        dispatch: Dispatch,
1391    ) -> Result<()> {
1392        let scopes = data.get("scopes").and_then(|v| v.as_str()).unwrap_or("");
1393
1394        Log::new(Some(status_tx))
1395            .info("Exchanging client credentials for access token".to_string());
1396
1397        // Get client config and provider metadata
1398        let (oidc_client_id, oidc_client_secret, provider_url) = app_state
1399            .with_client_mut(client_id, |client| {
1400                let client_id_str = client
1401                    .get_protocol_field("client_id")
1402                    .and_then(|v| v.as_str())
1403                    .map(|s| s.to_string())
1404                    .context("Missing client_id")
1405                    .ok()?;
1406                let client_secret_str = client
1407                    .get_protocol_field("client_secret")
1408                    .and_then(|v| v.as_str())
1409                    .map(|s| s.to_string())
1410                    .context("Missing client_secret for confidential client")
1411                    .ok()?;
1412                let provider = client
1413                    .get_protocol_field("provider_url")
1414                    .and_then(|v| v.as_str())
1415                    .map(|s| s.to_string())?;
1416                Some((client_id_str, client_secret_str, provider))
1417            })
1418            .await
1419            .flatten()
1420            .context("Missing client configuration")?;
1421
1422        let issuer_url = IssuerUrl::new(provider_url)?;
1423        let provider_metadata = CoreProviderMetadata::discover_async(issuer_url, http_hook).await?;
1424
1425        let client = CoreClient::from_provider_metadata(
1426            provider_metadata,
1427            OidcClientId::new(oidc_client_id),
1428            Some(ClientSecret::new(oidc_client_secret)),
1429        );
1430
1431        // Exchange client credentials
1432        let mut token_request = client.exchange_client_credentials();
1433
1434        // Add scopes
1435        for scope in scopes.split_whitespace() {
1436            if !scope.is_empty() {
1437                token_request = token_request.add_scope(Scope::new(scope.to_string()));
1438            }
1439        }
1440
1441        let token_response = token_request
1442            .request_async(http_hook)
1443            .await
1444            .context("Failed to exchange client credentials")?;
1445
1446        // Store tokens
1447        Self::store_and_notify_tokens(
1448            client_id,
1449            &token_response,
1450            llm_client,
1451            app_state,
1452            status_tx,
1453            protocol,
1454            dispatch,
1455        )
1456        .await?;
1457
1458        Ok(())
1459    }
1460
1461    /// Refresh access token
1462    async fn refresh_token(
1463        client_id: ClientId,
1464        llm_client: &OllamaClient,
1465        app_state: &Arc<AppState>,
1466        status_tx: &mpsc::UnboundedSender<String>,
1467        protocol: Arc<OpenIdConnectClientProtocol>,
1468        dispatch: Dispatch,
1469    ) -> Result<()> {
1470        Log::new(Some(status_tx)).info("Refreshing access token".to_string());
1471
1472        // Get refresh token and client config
1473        let (refresh_token_str, oidc_client_id, oidc_client_secret, provider_url) = app_state
1474            .with_client_mut(client_id, |client| {
1475                let refresh = client
1476                    .get_protocol_field("refresh_token")
1477                    .and_then(|v| v.as_str())
1478                    .map(|s| s.to_string())
1479                    .context("No refresh token available")
1480                    .ok()?;
1481                let client_id_str = client
1482                    .get_protocol_field("client_id")
1483                    .and_then(|v| v.as_str())
1484                    .map(|s| s.to_string())?;
1485                let client_secret_str = client
1486                    .get_protocol_field("client_secret")
1487                    .and_then(|v| v.as_str())
1488                    .map(|s| s.to_string());
1489                let provider = client
1490                    .get_protocol_field("provider_url")
1491                    .and_then(|v| v.as_str())
1492                    .map(|s| s.to_string())?;
1493                Some((refresh, client_id_str, client_secret_str, provider))
1494            })
1495            .await
1496            .flatten()
1497            .context("Missing refresh token or client configuration")?;
1498
1499        let issuer_url = IssuerUrl::new(provider_url)?;
1500        let provider_metadata = CoreProviderMetadata::discover_async(issuer_url, http_hook).await?;
1501
1502        let client = if let Some(secret) = oidc_client_secret {
1503            CoreClient::from_provider_metadata(
1504                provider_metadata,
1505                OidcClientId::new(oidc_client_id),
1506                Some(ClientSecret::new(secret)),
1507            )
1508        } else {
1509            CoreClient::from_provider_metadata(
1510                provider_metadata,
1511                OidcClientId::new(oidc_client_id),
1512                None,
1513            )
1514        };
1515
1516        use openidconnect::RefreshToken;
1517        let token_response = client
1518            .exchange_refresh_token(&RefreshToken::new(refresh_token_str))
1519            .request_async(http_hook)
1520            .await
1521            .context("Failed to refresh token")?;
1522
1523        // Store new tokens
1524        Self::store_and_notify_tokens(
1525            client_id,
1526            &token_response,
1527            llm_client,
1528            app_state,
1529            status_tx,
1530            protocol,
1531            dispatch,
1532        )
1533        .await?;
1534
1535        Ok(())
1536    }
1537
1538    /// Fetch UserInfo
1539    async fn fetch_userinfo(
1540        client_id: ClientId,
1541        llm_client: &OllamaClient,
1542        app_state: &Arc<AppState>,
1543        status_tx: &mpsc::UnboundedSender<String>,
1544        protocol: Arc<OpenIdConnectClientProtocol>,
1545        dispatch: Dispatch,
1546    ) -> Result<()> {
1547        Log::new(Some(status_tx)).info("Fetching UserInfo".to_string());
1548
1549        // Get access token and provider metadata
1550        let (access_token_str, oidc_client_id, oidc_client_secret, provider_url) = app_state
1551            .with_client_mut(client_id, |client| {
1552                let access = client
1553                    .get_protocol_field("access_token")
1554                    .and_then(|v| v.as_str())
1555                    .map(|s| s.to_string())
1556                    .context("No access token available")
1557                    .ok()?;
1558                let client_id_str = client
1559                    .get_protocol_field("client_id")
1560                    .and_then(|v| v.as_str())
1561                    .map(|s| s.to_string())?;
1562                let client_secret_str = client
1563                    .get_protocol_field("client_secret")
1564                    .and_then(|v| v.as_str())
1565                    .map(|s| s.to_string());
1566                let provider = client
1567                    .get_protocol_field("provider_url")
1568                    .and_then(|v| v.as_str())
1569                    .map(|s| s.to_string())?;
1570                Some((access, client_id_str, client_secret_str, provider))
1571            })
1572            .await
1573            .flatten()
1574            .context("Missing access token or client configuration")?;
1575
1576        let issuer_url = IssuerUrl::new(provider_url)?;
1577        let provider_metadata = CoreProviderMetadata::discover_async(issuer_url, http_hook).await?;
1578
1579        let client = if let Some(secret) = oidc_client_secret {
1580            CoreClient::from_provider_metadata(
1581                provider_metadata,
1582                OidcClientId::new(oidc_client_id),
1583                Some(ClientSecret::new(secret)),
1584            )
1585        } else {
1586            CoreClient::from_provider_metadata(
1587                provider_metadata,
1588                OidcClientId::new(oidc_client_id),
1589                None,
1590            )
1591        };
1592
1593        use openidconnect::AccessToken;
1594        let userinfo: CoreUserInfoClaims = client
1595            .user_info(AccessToken::new(access_token_str), None)
1596            .context("UserInfo endpoint not available")?
1597            .request_async(http_hook)
1598            .await
1599            .context("Failed to fetch UserInfo")?;
1600
1601        Log::new(Some(status_tx)).info(format!(
1602            "Received UserInfo for subject: {:?}",
1603            userinfo.subject()
1604        ));
1605
1606        // Call LLM with userinfo event
1607        let event = Event::new(
1608            &OIDC_CLIENT_USERINFO_RECEIVED_EVENT,
1609            serde_json::json!({
1610                "sub": userinfo.subject().as_str(),
1611                "claims": serde_json::to_value(&userinfo).unwrap_or(serde_json::json!({})),
1612            }),
1613        );
1614        Self::notify_event(
1615            client_id, event, dispatch, llm_client, app_state, status_tx, protocol,
1616        )
1617        .await;
1618
1619        Ok(())
1620    }
1621
1622    /// Store tokens and notify LLM
1623    #[allow(clippy::too_many_arguments)]
1624    async fn store_and_notify_tokens(
1625        client_id: ClientId,
1626        token_response: &CoreTokenResponse,
1627        llm_client: &OllamaClient,
1628        app_state: &Arc<AppState>,
1629        status_tx: &mpsc::UnboundedSender<String>,
1630        protocol: Arc<OpenIdConnectClientProtocol>,
1631        dispatch: Dispatch,
1632    ) -> Result<()> {
1633        let access_token = token_response.access_token().secret();
1634        let id_token = token_response
1635            .extra_fields()
1636            .id_token()
1637            .map(|t| t.to_string());
1638        let refresh_token = token_response
1639            .refresh_token()
1640            .map(|t| t.secret().to_string());
1641        let expires_in = token_response.expires_in().map(|d| d.as_secs());
1642        let token_type = token_response.token_type().as_ref();
1643
1644        Log::new(Some(status_tx)).info(format!("Received tokens (expires_in: {:?}s)", expires_in));
1645
1646        // Store tokens
1647        app_state
1648            .with_client_mut(client_id, |client| {
1649                client.set_protocol_field(
1650                    "access_token".to_string(),
1651                    serde_json::json!(access_token),
1652                );
1653                if let Some(id) = &id_token {
1654                    client.set_protocol_field("id_token".to_string(), serde_json::json!(id));
1655                }
1656                if let Some(refresh) = &refresh_token {
1657                    client.set_protocol_field(
1658                        "refresh_token".to_string(),
1659                        serde_json::json!(refresh),
1660                    );
1661                }
1662            })
1663            .await;
1664
1665        // Call LLM with token received event.
1666        // The tokens are stored above and every action that needs one
1667        // (`fetch_userinfo`, `refresh_token`, `validate_token`) reads it back out of
1668        // `protocol_data`. The model therefore never needs the secret itself, and putting it
1669        // in the event copied a live bearer token, a refresh token and an id_token into the
1670        // LLM prompt — which goes to whatever backend is configured, is written to
1671        // `netget.log`, and is echoed onto the status stream. The sibling `oauth2` client has
1672        // always redacted these; this one did not. Report presence and metadata instead.
1673        let event = Event::new(
1674            &OIDC_CLIENT_TOKEN_RECEIVED_EVENT,
1675            serde_json::json!({
1676                "access_token": "[REDACTED]",
1677                "id_token": if id_token.is_some() { "[REDACTED]" } else { "" },
1678                "refresh_token": if refresh_token.is_some() { "[REDACTED]" } else { "" },
1679                "expires_in": expires_in,
1680                "token_type": token_type,
1681            }),
1682        );
1683        Self::notify_event(
1684            client_id, event, dispatch, llm_client, app_state, status_tx, protocol,
1685        )
1686        .await;
1687
1688        Ok(())
1689    }
1690
1691    /// Report one event to the LLM, inline or from a registered task.
1692    #[allow(clippy::too_many_arguments)]
1693    async fn notify_event(
1694        client_id: ClientId,
1695        event: Event,
1696        dispatch: Dispatch,
1697        llm_client: &OllamaClient,
1698        app_state: &Arc<AppState>,
1699        status_tx: &mpsc::UnboundedSender<String>,
1700        protocol: Arc<OpenIdConnectClientProtocol>,
1701    ) {
1702        match dispatch {
1703            Dispatch::Inline => {
1704                Self::raise_event(
1705                    client_id,
1706                    event,
1707                    llm_client.clone(),
1708                    app_state.clone(),
1709                    status_tx.clone(),
1710                    protocol,
1711                )
1712                .await
1713            }
1714            Dispatch::Deferred => {
1715                let handle = tokio::spawn(Self::raise_event(
1716                    client_id,
1717                    event,
1718                    llm_client.clone(),
1719                    app_state.clone(),
1720                    status_tx.clone(),
1721                    protocol,
1722                ));
1723                // Registered so stop_client aborts an in-flight LLM call for this event.
1724                app_state.register_client_task(client_id, handle).await;
1725            }
1726        }
1727    }
1728
1729    /// The event -> LLM round-trip itself, plus whatever follow-up actions it answers with.
1730    async fn raise_event(
1731        client_id: ClientId,
1732        event: Event,
1733        llm_client: OllamaClient,
1734        app_state: Arc<AppState>,
1735        status_tx: mpsc::UnboundedSender<String>,
1736        protocol: Arc<OpenIdConnectClientProtocol>,
1737    ) {
1738        let Some(instruction) = app_state.get_instruction_for_client(client_id).await else {
1739            return;
1740        };
1741        let memory = app_state
1742            .get_memory_for_client(client_id)
1743            .await
1744            .unwrap_or_default();
1745
1746        match call_llm_for_client(
1747            &llm_client,
1748            &app_state,
1749            client_id.to_string(),
1750            &instruction,
1751            &memory,
1752            Some(&event),
1753            protocol.as_ref(),
1754            &status_tx,
1755        )
1756        .await
1757        {
1758            Ok(ClientLlmResult {
1759                actions,
1760                memory_updates,
1761            }) => {
1762                if let Some(mem) = memory_updates {
1763                    app_state.set_memory_for_client(client_id, mem).await;
1764                }
1765
1766                // Execute follow-up actions
1767                for action in actions {
1768                    if let Err(e) = Self::execute_llm_action(
1769                        action,
1770                        client_id,
1771                        &llm_client,
1772                        &app_state,
1773                        &status_tx,
1774                        protocol.clone(),
1775                    )
1776                    .await
1777                    {
1778                        error!("Failed to execute follow-up action: {}", e);
1779                    }
1780                }
1781            }
1782            Err(e) => {
1783                error!("LLM error for OIDC client {}: {}", client_id, e);
1784            }
1785        }
1786    }
1787}