Skip to main content

opensession_api_types/
oauth.rs

1//! Generic OAuth2 provider support.
2//!
3//! Config-driven: no provider-specific code branches. Any OAuth2 provider
4//! (GitHub, GitLab, Gitea, OIDC-compatible) can be added via configuration.
5//!
6//! This module contains only types, URL builders, and JSON parsing.
7//! No HTTP calls or DB access — those live in the backend adapters.
8
9use serde::{Deserialize, Serialize};
10
11use crate::ServiceError;
12
13// ── Provider Configuration ──────────────────────────────────────────────────
14
15/// OAuth2 provider configuration. Loaded from environment variables or config file.
16#[derive(Debug, Clone, Serialize, Deserialize)]
17pub struct OAuthProviderConfig {
18    /// Unique provider identifier: "github", "gitlab-corp", "gitea-internal"
19    pub id: String,
20    /// UI display name: "GitHub", "GitLab (Corp)"
21    pub display_name: String,
22
23    // OAuth2 endpoints
24    pub authorize_url: String,
25    pub token_url: String,
26    pub userinfo_url: String,
27    /// Optional separate email endpoint (GitHub-specific: /user/emails)
28    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    /// JSON field mapping from userinfo response to internal fields
36    pub field_map: OAuthFieldMap,
37
38    /// Skip TLS verification for self-hosted instances (dev only)
39    #[serde(default)]
40    pub tls_skip_verify: bool,
41
42    /// External URL for browser redirects (may differ from token_url for Docker setups)
43    pub external_authorize_url: Option<String>,
44}
45
46/// Maps provider-specific JSON field names to our internal fields.
47#[derive(Debug, Clone, Serialize, Deserialize)]
48pub struct OAuthFieldMap {
49    /// Field containing the user's unique ID: "id" (GitHub/GitLab) or "sub" (OIDC)
50    pub id: String,
51    /// Field containing the username: "login" (GitHub) or "username" (GitLab)
52    pub username: String,
53    /// Field containing the email: "email"
54    pub email: String,
55    /// Field containing the avatar URL: "avatar_url" or "picture"
56    pub avatar: String,
57}
58
59/// Normalized user info extracted from any OAuth provider's userinfo response.
60#[derive(Debug, Clone)]
61pub struct OAuthUserInfo {
62    /// Provider config id (e.g. "github")
63    pub provider_id: String,
64    /// Provider-side user ID (as string)
65    pub provider_user_id: String,
66    pub username: String,
67    pub email: Option<String>,
68    pub avatar_url: Option<String>,
69}
70
71// ── URL Builders (pure functions, no HTTP) ──────────────────────────────────
72
73/// Build the OAuth authorize URL that the user's browser should be redirected to.
74pub 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
94/// Build the JSON body for the token exchange request.
95pub 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
109/// Extract normalized user info from a provider's userinfo JSON response.
110///
111/// `email_json` is an optional array of email objects (GitHub `/user/emails` format)
112/// used when the primary userinfo endpoint doesn't include the email.
113pub 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    // Extract provider user ID — may be number or string depending on provider
119    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    // Email: try userinfo first, then email_json (GitHub format: [{email, primary, verified}])
136    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
162// ── Provider Presets ────────────────────────────────────────────────────────
163
164/// Create a GitHub OAuth2 provider config. Only needs client credentials.
165pub 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
187/// Create a GitLab OAuth2 provider config for a given instance URL.
188///
189/// `instance_url` is the server-accessible URL (e.g. `http://gitlab:80` in Docker).
190/// `external_url` is the browser-accessible URL (e.g. `http://localhost:8929`).
191/// If `external_url` is None, `instance_url` is used for browser redirects too.
192pub 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, // GitLab includes email in /api/v4/user
210        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// ── API Response Types ──────────────────────────────────────────────────────
225
226/// Available auth providers (returned by GET /api/auth/providers).
227#[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/// Public info about an OAuth provider.
236#[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/// A linked OAuth provider shown in user settings.
245#[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
254// ── Helpers ─────────────────────────────────────────────────────────────────
255
256fn urlencoding(s: &str) -> String {
257    // Minimal URL-encoding for OAuth parameters
258    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}