1use serde::{Deserialize, Serialize};
10
11use crate::ServiceError;
12
13#[derive(Debug, Clone, Serialize, Deserialize)]
17pub struct OAuthProviderConfig {
18 pub id: String,
20 pub display_name: String,
22
23 pub authorize_url: String,
25 pub token_url: String,
26 pub userinfo_url: String,
27 pub email_url: Option<String>,
29
30 pub client_id: String,
31 #[serde(skip_serializing)]
32 pub client_secret: String,
33 pub scopes: String,
34
35 pub field_map: OAuthFieldMap,
37
38 #[serde(default)]
40 pub tls_skip_verify: bool,
41
42 pub external_authorize_url: Option<String>,
44}
45
46#[derive(Debug, Clone, Serialize, Deserialize)]
48pub struct OAuthFieldMap {
49 pub id: String,
51 pub username: String,
53 pub email: String,
55 pub avatar: String,
57}
58
59#[derive(Debug, Clone)]
61pub struct OAuthUserInfo {
62 pub provider_id: String,
64 pub provider_user_id: String,
66 pub username: String,
67 pub email: Option<String>,
68 pub avatar_url: Option<String>,
69}
70
71pub fn build_authorize_url(
75 config: &OAuthProviderConfig,
76 redirect_uri: &str,
77 state: &str,
78) -> String {
79 let base = config
80 .external_authorize_url
81 .as_deref()
82 .unwrap_or(&config.authorize_url);
83
84 format!(
85 "{}?client_id={}&redirect_uri={}&state={}&scope={}&response_type=code",
86 base,
87 urlencoding(&config.client_id),
88 urlencoding(redirect_uri),
89 urlencoding(state),
90 urlencoding(&config.scopes),
91 )
92}
93
94pub fn build_token_request_body(
96 config: &OAuthProviderConfig,
97 code: &str,
98 redirect_uri: &str,
99) -> serde_json::Value {
100 serde_json::json!({
101 "client_id": config.client_id,
102 "client_secret": config.client_secret,
103 "code": code,
104 "grant_type": "authorization_code",
105 "redirect_uri": redirect_uri,
106 })
107}
108
109pub fn extract_user_info(
114 config: &OAuthProviderConfig,
115 userinfo_json: &serde_json::Value,
116 email_json: Option<&[serde_json::Value]>,
117) -> Result<OAuthUserInfo, ServiceError> {
118 let provider_user_id = match &userinfo_json[&config.field_map.id] {
120 serde_json::Value::Number(n) => n.to_string(),
121 serde_json::Value::String(s) => s.clone(),
122 _ => {
123 return Err(ServiceError::Internal(format!(
124 "OAuth userinfo missing '{}' field",
125 config.field_map.id
126 )))
127 }
128 };
129
130 let username = userinfo_json[&config.field_map.username]
131 .as_str()
132 .unwrap_or("unknown")
133 .to_string();
134
135 let email = userinfo_json[&config.field_map.email]
137 .as_str()
138 .map(|s| s.to_string())
139 .or_else(|| {
140 email_json.and_then(|emails| {
141 emails
142 .iter()
143 .find(|e| e["primary"].as_bool() == Some(true))
144 .and_then(|e| e["email"].as_str())
145 .map(|s| s.to_string())
146 })
147 });
148
149 let avatar_url = userinfo_json[&config.field_map.avatar]
150 .as_str()
151 .map(|s| s.to_string());
152
153 Ok(OAuthUserInfo {
154 provider_id: config.id.clone(),
155 provider_user_id,
156 username,
157 email,
158 avatar_url,
159 })
160}
161
162pub fn github_preset(client_id: String, client_secret: String) -> OAuthProviderConfig {
166 OAuthProviderConfig {
167 id: "github".into(),
168 display_name: "GitHub".into(),
169 authorize_url: "https://github.com/login/oauth/authorize".into(),
170 token_url: "https://github.com/login/oauth/access_token".into(),
171 userinfo_url: "https://api.github.com/user".into(),
172 email_url: Some("https://api.github.com/user/emails".into()),
173 client_id,
174 client_secret,
175 scopes: "read:user,user:email".into(),
176 field_map: OAuthFieldMap {
177 id: "id".into(),
178 username: "login".into(),
179 email: "email".into(),
180 avatar: "avatar_url".into(),
181 },
182 tls_skip_verify: false,
183 external_authorize_url: None,
184 }
185}
186
187pub fn gitlab_preset(
193 instance_url: String,
194 external_url: Option<String>,
195 client_id: String,
196 client_secret: String,
197) -> OAuthProviderConfig {
198 let base = instance_url.trim_end_matches('/');
199 let ext_base = external_url
200 .as_deref()
201 .map(|u| u.trim_end_matches('/').to_string());
202
203 OAuthProviderConfig {
204 id: "gitlab".into(),
205 display_name: "GitLab".into(),
206 authorize_url: format!("{base}/oauth/authorize"),
207 token_url: format!("{base}/oauth/token"),
208 userinfo_url: format!("{base}/api/v4/user"),
209 email_url: None, client_id,
211 client_secret,
212 scopes: "read_user".into(),
213 field_map: OAuthFieldMap {
214 id: "id".into(),
215 username: "username".into(),
216 email: "email".into(),
217 avatar: "avatar_url".into(),
218 },
219 tls_skip_verify: false,
220 external_authorize_url: ext_base.map(|b| format!("{b}/oauth/authorize")),
221 }
222}
223
224#[derive(Debug, Serialize, Deserialize)]
228#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
229#[cfg_attr(feature = "ts", ts(export))]
230pub struct AuthProvidersResponse {
231 pub email_password: bool,
232 pub oauth: Vec<OAuthProviderInfo>,
233}
234
235#[derive(Debug, Serialize, Deserialize)]
237#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
238#[cfg_attr(feature = "ts", ts(export))]
239pub struct OAuthProviderInfo {
240 pub id: String,
241 pub display_name: String,
242}
243
244#[derive(Debug, Serialize, Deserialize)]
246#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
247#[cfg_attr(feature = "ts", ts(export))]
248pub struct LinkedProvider {
249 pub provider: String,
250 pub provider_username: String,
251 pub display_name: String,
252}
253
254fn urlencoding(s: &str) -> String {
257 let mut out = String::with_capacity(s.len());
259 for b in s.bytes() {
260 match b {
261 b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
262 out.push(b as char);
263 }
264 _ => {
265 out.push('%');
266 out.push(char::from(b"0123456789ABCDEF"[(b >> 4) as usize]));
267 out.push(char::from(b"0123456789ABCDEF"[(b & 0x0f) as usize]));
268 }
269 }
270 }
271 out
272}