1use 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#[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
24pub trait OAuthProvider: Send + Sync {
26 fn name(&self) -> &str;
28
29 fn authorize_url(&self, state: &str) -> Result<String, AuthError>;
31
32 fn exchange_code(&self, code: &str) -> Result<OAuthTokenResponse, AuthError>;
34}
35
36pub 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
148pub 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
156pub 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
165pub 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}