Skip to main content

doido_auth/
oauth.rs

1//! OAuth provider registry — abstract interface + config-driven OAuth 2.0 impl.
2
3use crate::config::{OAuthProviderConfig, OAuthProviderType};
4use crate::error::AuthError;
5use doido_core::Result;
6use serde::{Deserialize, Serialize};
7use std::collections::HashMap;
8use std::sync::{Arc, OnceLock, RwLock};
9
10/// Token payload returned after a successful authorization-code exchange.
11#[derive(Debug, Clone, Serialize, Deserialize)]
12pub struct OAuthTokenResponse {
13    pub access_token: String,
14    #[serde(default)]
15    pub token_type: Option<String>,
16    #[serde(default)]
17    pub refresh_token: Option<String>,
18    #[serde(default)]
19    pub expires_in: Option<u64>,
20    #[serde(default)]
21    pub id_token: Option<String>,
22}
23
24/// Pluggable OAuth provider — apps register custom impls or use config-backed ones.
25pub trait OAuthProvider: Send + Sync {
26    /// Registry key (matches the `:provider` route segment and config entry name).
27    fn name(&self) -> &str;
28
29    /// Build the authorization redirect URL for the given CSRF `state`.
30    fn authorize_url(&self, state: &str) -> Result<String, AuthError>;
31
32    /// Exchange an authorization `code` for tokens.
33    fn exchange_code(&self, code: &str) -> Result<OAuthTokenResponse, AuthError>;
34}
35
36/// Config-driven OAuth 2.0 authorization-code provider.
37pub struct OAuth2Provider {
38    name: String,
39    config: OAuthProviderConfig,
40}
41
42impl OAuth2Provider {
43    pub fn new(name: impl Into<String>, config: OAuthProviderConfig) -> Self {
44        Self {
45            name: name.into(),
46            config,
47        }
48    }
49
50    pub fn from_config(
51        name: impl Into<String>,
52        config: OAuthProviderConfig,
53    ) -> Result<Self, AuthError> {
54        let name = name.into();
55        if config.provider_type != OAuthProviderType::Oauth2 {
56            return Err(AuthError::OAuth(format!("provider {name} is not oauth2")));
57        }
58        Ok(Self::new(name, config))
59    }
60}
61
62impl OAuthProvider for OAuth2Provider {
63    fn name(&self) -> &str {
64        &self.name
65    }
66
67    fn authorize_url(&self, state: &str) -> Result<String, AuthError> {
68        let client_id = self
69            .config
70            .client_id
71            .as_deref()
72            .ok_or_else(|| AuthError::OAuth("missing client_id".into()))?;
73        let authorize_url = self
74            .config
75            .authorize_url
76            .as_deref()
77            .ok_or_else(|| AuthError::OAuth("missing authorize_url".into()))?;
78        let redirect_uri = self
79            .config
80            .redirect_uri
81            .as_deref()
82            .ok_or_else(|| AuthError::OAuth("missing redirect_uri".into()))?;
83
84        let scope = if self.config.scopes.is_empty() {
85            String::new()
86        } else {
87            format!("&scope={}", url_encode(&self.config.scopes.join(" ")))
88        };
89        Ok(format!(
90            "{authorize_url}?client_id={}&redirect_uri={}&response_type=code&state={}{scope}",
91            url_encode(client_id),
92            url_encode(redirect_uri),
93            url_encode(state),
94        ))
95    }
96
97    fn exchange_code(&self, code: &str) -> Result<OAuthTokenResponse, AuthError> {
98        let token_url = self
99            .config
100            .token_url
101            .as_deref()
102            .ok_or_else(|| AuthError::OAuth("missing token_url".into()))?;
103        let client_id = self
104            .config
105            .client_id
106            .as_deref()
107            .ok_or_else(|| AuthError::OAuth("missing client_id".into()))?;
108        let client_secret = self
109            .config
110            .client_secret
111            .as_deref()
112            .ok_or_else(|| AuthError::OAuth("missing client_secret".into()))?;
113        let redirect_uri = self
114            .config
115            .redirect_uri
116            .as_deref()
117            .ok_or_else(|| AuthError::OAuth("missing redirect_uri".into()))?;
118
119        let body = format!(
120            "grant_type=authorization_code&code={code}&redirect_uri={redirect_uri}&client_id={client_id}&client_secret={client_secret}"
121        );
122        let response = ureq::post(token_url)
123            .header("Content-Type", "application/x-www-form-urlencoded")
124            .send(body)
125            .map_err(|e| AuthError::OAuth(format!("token exchange failed: {e}")))?;
126
127        if !response.status().is_success() {
128            let status = response.status();
129            let text = response.into_body().read_to_string().unwrap_or_default();
130            return Err(AuthError::OAuth(format!(
131                "token exchange HTTP {status}: {text}"
132            )));
133        }
134
135        response
136            .into_body()
137            .read_json::<OAuthTokenResponse>()
138            .map_err(|e| AuthError::OAuth(format!("invalid token response: {e}")))
139    }
140}
141
142static PROVIDERS: OnceLock<RwLock<HashMap<String, Arc<dyn OAuthProvider>>>> = OnceLock::new();
143
144fn providers() -> &'static RwLock<HashMap<String, Arc<dyn OAuthProvider>>> {
145    PROVIDERS.get_or_init(|| RwLock::new(HashMap::new()))
146}
147
148/// Register a custom OAuth provider at boot.
149pub fn register_provider(provider: Arc<dyn OAuthProvider>) {
150    providers()
151        .write()
152        .expect("oauth provider lock")
153        .insert(provider.name().to_string(), provider);
154}
155
156/// Look up a registered OAuth provider by name.
157pub fn get_provider(name: &str) -> Option<Arc<dyn OAuthProvider>> {
158    providers()
159        .read()
160        .expect("oauth provider lock")
161        .get(name)
162        .cloned()
163}
164
165/// Build providers from config entries (OAuth 2.0 only in v1).
166pub fn providers_from_config(
167    oauth: &HashMap<String, OAuthProviderConfig>,
168) -> HashMap<String, Arc<dyn OAuthProvider>> {
169    let mut map = HashMap::new();
170    for (name, cfg) in oauth {
171        if cfg.provider_type == OAuthProviderType::Oauth2 {
172            if let Ok(provider) = OAuth2Provider::from_config(name, cfg.clone()) {
173                map.insert(name.clone(), Arc::new(provider) as Arc<dyn OAuthProvider>);
174            }
175        }
176    }
177    map
178}
179
180fn url_encode(value: &str) -> String {
181    let mut out = String::new();
182    for b in value.bytes() {
183        match b {
184            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
185                out.push(b as char);
186            }
187            _ => out.push_str(&format!("%{b:02X}")),
188        }
189    }
190    out
191}