diff --git a/src/client/oauth.rs b/src/client/oauth.rs index c435dbf..641ddfe 100644 --- a/src/client/oauth.rs +++ b/src/client/oauth.rs @@ -5,6 +5,7 @@ use anyhow::{Result, anyhow, bail}; use base64::Engine; use base64::engine::general_purpose::URL_SAFE_NO_PAD; use chrono::Utc; +use indexmap::IndexMap; use inquire::Text; use reqwest::{Client as ReqwestClient, RequestBuilder}; use serde::{Deserialize, Serialize}; @@ -17,11 +18,100 @@ use std::net::TcpListener; use url::Url; use uuid::Uuid; +#[derive(Debug, Clone, Copy, Deserialize)] +#[serde(rename_all = "snake_case")] pub enum TokenRequestFormat { Json, FormUrlEncoded, } +#[derive(Debug, Clone, Copy, Deserialize, Default)] +#[serde(rename_all = "snake_case")] +pub enum OAuthFlow { + #[default] + Pkce, + ClientCredentials, +} + +/// Runtime OAuth configuration merged from `models.yaml` provider defaults +/// and user config `clients[i].oauth` overrides. +/// +/// Every field except `client_id`, `token_url`, and `flow` is optional so that +/// user config can override individual fields without restating the entire block. +#[derive(Debug, Clone, Deserialize)] +pub struct OAuthConfig { + pub client_id: String, + pub token_url: String, + #[serde(default)] + pub flow: OAuthFlow, + + pub client_secret: Option, + pub authorize_url: Option, + pub redirect_uri: Option, + pub redirect_port: Option, + #[serde(default)] + pub scopes: Vec, + pub token_request_format: Option, + #[serde(default)] + pub extra_authorize_params: IndexMap, + #[serde(default)] + pub extra_token_headers: IndexMap, + #[serde(default)] + pub extra_request_headers: IndexMap, + #[serde(default)] + pub echo_pkce_in_token_exchange: bool, + #[serde(default = "default_true")] + pub include_state_in_token_exchange: bool, +} + +fn default_true() -> bool { + true +} + +impl OAuthConfig { + /// Merge a user override into `self` field-by-field. User values win. + /// Uses `json_patch::merge`-like semantics (see `common.rs:apply_patch`) — + /// `None` in override means "keep base"; explicit values replace. + pub fn merge(mut self, override_cfg: OAuthConfig) -> Self { + self.client_id = override_cfg.client_id; + self.token_url = override_cfg.token_url; + self.flow = override_cfg.flow; + if override_cfg.client_secret.is_some() { + self.client_secret = override_cfg.client_secret; + } + if override_cfg.authorize_url.is_some() { + self.authorize_url = override_cfg.authorize_url; + } + if override_cfg.redirect_uri.is_some() { + self.redirect_uri = override_cfg.redirect_uri; + } + if override_cfg.redirect_port.is_some() { + self.redirect_port = override_cfg.redirect_port; + } + if !override_cfg.scopes.is_empty() { + self.scopes = override_cfg.scopes; + } + if override_cfg.token_request_format.is_some() { + self.token_request_format = override_cfg.token_request_format; + } + if !override_cfg.extra_authorize_params.is_empty() { + self.extra_authorize_params + .extend(override_cfg.extra_authorize_params); + } + if !override_cfg.extra_token_headers.is_empty() { + self.extra_token_headers + .extend(override_cfg.extra_token_headers); + } + if !override_cfg.extra_request_headers.is_empty() { + self.extra_request_headers + .extend(override_cfg.extra_request_headers); + } + self.echo_pkce_in_token_exchange = override_cfg.echo_pkce_in_token_exchange; + self.include_state_in_token_exchange = override_cfg.include_state_in_token_exchange; + self + } +} + pub trait OAuthProvider: Send + Sync { fn provider_name(&self) -> &str; fn client_id(&self) -> &str; @@ -65,6 +155,14 @@ pub trait OAuthProvider: Send + Sync { fn include_state_in_token_exchange(&self) -> bool { true } + + fn flow(&self) -> OAuthFlow { + OAuthFlow::Pkce + } + + fn echo_pkce_in_token_exchange(&self) -> bool { + false + } } #[derive(Debug, Clone, Serialize, Deserialize)]