1use std::time::Duration;
2
3use async_trait::async_trait;
4use reqwest::Url;
5use serde::Deserialize;
6use tracing::info;
7
8use crate::error::AuthError;
9use crate::oauth_provider::{AuthorizeUrlRequest, OAuthProvider, ProviderExchange};
10use crate::provider_http::{RequestErrors, RequestTrace, build_authorize_url, read_json_response};
11use crate::util::fingerprint;
12
13const GITHUB_AUTHORIZE_ENDPOINT: &str = "https://github.com/login/oauth/authorize";
14const GITHUB_TOKEN_ENDPOINT: &str = "https://github.com/login/oauth/access_token";
15const GITHUB_USER_ENDPOINT: &str = "https://api.github.com/user";
16const GITHUB_USER_EMAILS_ENDPOINT: &str = "https://api.github.com/user/emails";
17const GITHUB_HTTP_TIMEOUT: Duration = Duration::from_secs(30);
18const GITHUB_USER_AGENT: &str = "soma-auth";
19
20#[derive(Clone)]
21pub struct GitHubProvider {
22 pub client_id: String,
23 pub client_secret: String,
24 pub redirect_uri: Url,
25 pub scopes: Vec<String>,
26 pub http: reqwest::Client,
27 authorize_endpoint: Url,
28 token_endpoint: Url,
29 user_endpoint: Url,
30 user_emails_endpoint: Url,
31}
32
33impl std::fmt::Debug for GitHubProvider {
34 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
35 f.debug_struct("GitHubProvider")
36 .field("client_id", &self.client_id)
37 .field("redirect_uri", &self.redirect_uri)
38 .field("scopes", &self.scopes)
39 .finish_non_exhaustive()
40 }
41}
42
43#[derive(Debug, Deserialize)]
44struct GitHubTokenResponse {
45 access_token: String,
46}
47
48#[derive(Debug, Deserialize)]
55#[serde(untagged)]
56enum GitHubTokenResult {
57 Success(GitHubTokenResponse),
58 Error(GitHubTokenErrorResponse),
59}
60
61#[derive(Debug, Deserialize)]
62struct GitHubTokenErrorResponse {
63 error: String,
64 #[serde(default)]
65 error_description: Option<String>,
66}
67
68#[derive(Debug, Deserialize)]
69struct GitHubUser {
70 id: u64,
71 #[serde(default)]
72 email: Option<String>,
73}
74
75#[derive(Debug, Deserialize)]
76struct GitHubUserEmail {
77 email: String,
78 primary: bool,
79 verified: bool,
80}
81
82impl GitHubProvider {
83 pub fn new(
84 client_id: String,
85 client_secret: String,
86 redirect_uri: Url,
87 ) -> Result<Self, AuthError> {
88 crate::provider_http::install_rustls_default_once();
89 let http = reqwest::Client::builder()
90 .timeout(GITHUB_HTTP_TIMEOUT)
91 .user_agent(GITHUB_USER_AGENT)
92 .build()
93 .map_err(|error| {
94 AuthError::Storage(format!("build github oauth http client: {error}"))
95 })?;
96 let authorize_endpoint = Url::parse(GITHUB_AUTHORIZE_ENDPOINT).map_err(|error| {
97 AuthError::Config(format!("parse github authorize endpoint: {error}"))
98 })?;
99 let token_endpoint = Url::parse(GITHUB_TOKEN_ENDPOINT)
100 .map_err(|error| AuthError::Config(format!("parse github token endpoint: {error}")))?;
101 let user_endpoint = Url::parse(GITHUB_USER_ENDPOINT)
102 .map_err(|error| AuthError::Config(format!("parse github user endpoint: {error}")))?;
103 let user_emails_endpoint = Url::parse(GITHUB_USER_EMAILS_ENDPOINT).map_err(|error| {
104 AuthError::Config(format!("parse github user emails endpoint: {error}"))
105 })?;
106 Ok(Self {
107 client_id,
108 client_secret,
109 redirect_uri,
110 scopes: vec!["read:user".to_string(), "user:email".to_string()],
111 http,
112 authorize_endpoint,
113 token_endpoint,
114 user_endpoint,
115 user_emails_endpoint,
116 })
117 }
118
119 #[cfg(test)]
120 #[must_use]
121 pub fn with_endpoints(
122 mut self,
123 authorize_endpoint: Url,
124 token_endpoint: Url,
125 user_endpoint: Url,
126 user_emails_endpoint: Url,
127 ) -> Self {
128 self.authorize_endpoint = authorize_endpoint;
129 self.token_endpoint = token_endpoint;
130 self.user_endpoint = user_endpoint;
131 self.user_emails_endpoint = user_emails_endpoint;
132 self
133 }
134
135 async fn fetch_exchange(
144 &self,
145 payload: GitHubTokenResponse,
146 ) -> Result<ProviderExchange, AuthError> {
147 let (user, verified_email) = tokio::try_join!(
148 self.fetch_user(&payload.access_token),
149 self.fetch_primary_verified_email(&payload.access_token),
150 )?;
151
152 let (email, email_verified) = match verified_email {
153 Some(verified) => (Some(verified), Some(true)),
154 None => (user.email, None),
155 };
156
157 info!(
158 provider = "github",
159 subject_id = %fingerprint(&user.id.to_string()),
160 "oauth upstream code exchange succeeded"
161 );
162
163 Ok(ProviderExchange {
173 subject: user.id.to_string(),
174 email,
175 email_verified,
176 access_token: payload.access_token,
177 refresh_token: None,
178 expires_in: None,
179 id_token: None,
180 })
181 }
182
183 async fn fetch_user(&self, access_token: &str) -> Result<GitHubUser, AuthError> {
184 let trace = RequestTrace::start("github", "fetch_user", "GET", &self.user_endpoint);
185 read_json_response(
186 trace,
187 self.http
188 .get(self.user_endpoint.clone())
189 .bearer_auth(access_token)
190 .header(reqwest::header::ACCEPT, "application/vnd.github+json"),
191 RequestErrors::new(
192 "github",
193 "fetch github user",
194 "github user endpoint error",
195 "decode github user response",
196 ),
197 )
198 .await
199 }
200
201 async fn fetch_primary_verified_email(
202 &self,
203 access_token: &str,
204 ) -> Result<Option<String>, AuthError> {
205 let trace = RequestTrace::start(
206 "github",
207 "fetch_user_emails",
208 "GET",
209 &self.user_emails_endpoint,
210 );
211 let emails: Vec<GitHubUserEmail> = read_json_response(
212 trace,
213 self.http
214 .get(self.user_emails_endpoint.clone())
215 .bearer_auth(access_token)
216 .header(reqwest::header::ACCEPT, "application/vnd.github+json"),
217 RequestErrors::new(
218 "github",
219 "fetch github user emails",
220 "github user emails endpoint error",
221 "decode github user emails response",
222 ),
223 )
224 .await?;
225 Ok(emails
226 .into_iter()
227 .find(|entry| entry.primary && entry.verified)
228 .map(|entry| entry.email))
229 }
230}
231
232#[async_trait]
233impl OAuthProvider for GitHubProvider {
234 fn provider_id(&self) -> &'static str {
235 "github"
236 }
237
238 fn callback_path(&self) -> &str {
239 self.redirect_uri.path()
240 }
241
242 fn authorize_url(&self, request: &AuthorizeUrlRequest) -> Result<Url, AuthError> {
243 Ok(build_authorize_url(
244 &self.authorize_endpoint,
245 &self.client_id,
246 &self.redirect_uri,
247 &self.scopes,
248 request,
249 &[],
250 ))
251 }
252
253 async fn exchange_code(
254 &self,
255 code: &str,
256 code_verifier: &str,
257 ) -> Result<ProviderExchange, AuthError> {
258 let trace = RequestTrace::start("github", "code_exchange", "POST", &self.token_endpoint);
259 info!(
260 provider = "github",
261 oauth_code_id = %fingerprint(code),
262 redirect_uri = %self.redirect_uri,
263 "oauth upstream code exchange started"
264 );
265 let payload: GitHubTokenResult = read_json_response(
266 trace,
267 self.http
268 .post(self.token_endpoint.clone())
269 .header(reqwest::header::ACCEPT, "application/json")
270 .form(&[
271 ("grant_type", "authorization_code"),
272 ("code", code),
273 ("client_id", self.client_id.as_str()),
274 ("client_secret", self.client_secret.as_str()),
275 ("redirect_uri", self.redirect_uri.as_str()),
276 ("code_verifier", code_verifier),
277 ]),
278 RequestErrors::new(
279 "github",
280 "exchange github auth code",
281 "github token endpoint error",
282 "decode github token response",
283 ),
284 )
285 .await?;
286 let payload = match payload {
287 GitHubTokenResult::Success(payload) => payload,
288 GitHubTokenResult::Error(error) => {
289 return Err(AuthError::InvalidGrant(format!(
290 "github token exchange failed: {} ({})",
291 error.error,
292 error
293 .error_description
294 .as_deref()
295 .unwrap_or("no description")
296 )));
297 }
298 };
299 self.fetch_exchange(payload).await
300 }
301
302 async fn refresh(&self, _refresh_token: &str) -> Result<ProviderExchange, AuthError> {
303 Err(AuthError::Config(
304 "github oauth apps do not support token refresh — access tokens do not expire; \
305 the user must re-authenticate via github once their local soma-issued refresh \
306 token expires"
307 .to_string(),
308 ))
309 }
310}
311
312#[cfg(test)]
313mod tests {
314 use serde_json::json;
315 use wiremock::matchers::{header, method, path};
316 use wiremock::{Mock, MockServer, ResponseTemplate};
317
318 use super::{AuthorizeUrlRequest, GitHubProvider};
319 use crate::error::AuthError;
320 use crate::oauth_provider::OAuthProvider;
321
322 #[tokio::test]
323 async fn github_exchange_uses_numeric_id_as_subject_and_primary_verified_email() {
324 let server = MockServer::start().await;
325 Mock::given(method("POST"))
326 .and(path("/login/oauth/access_token"))
327 .and(header("accept", "application/json"))
328 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
329 "access_token": "gho_test-token",
330 "scope": "read:user,user:email",
331 "token_type": "bearer",
332 })))
333 .mount(&server)
334 .await;
335 Mock::given(method("GET"))
336 .and(path("/user"))
337 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
338 "id": 9182310,
339 "login": "octocat",
340 "email": null,
341 })))
342 .mount(&server)
343 .await;
344 Mock::given(method("GET"))
345 .and(path("/user/emails"))
346 .respond_with(ResponseTemplate::new(200).set_body_json(json!([
347 {"email": "secondary@example.com", "primary": false, "verified": true},
348 {"email": "primary@example.com", "primary": true, "verified": true},
349 ])))
350 .mount(&server)
351 .await;
352
353 let base = url::Url::parse(&server.uri()).unwrap();
354 let provider = test_github_provider().with_endpoints(
355 base.join("login/oauth/authorize").unwrap(),
356 base.join("login/oauth/access_token").unwrap(),
357 base.join("user").unwrap(),
358 base.join("user/emails").unwrap(),
359 );
360
361 let exchange = provider.exchange_code("code", "verifier").await.unwrap();
362 assert_eq!(exchange.subject, "9182310");
363 assert_eq!(exchange.email.as_deref(), Some("primary@example.com"));
364 assert_eq!(exchange.email_verified, Some(true));
365 assert!(exchange.id_token.is_none());
366 assert!(exchange.refresh_token.is_none());
367 }
368
369 #[tokio::test]
374 async fn github_exchange_falls_back_to_inline_user_email_when_no_primary_verified_entry() {
375 let server = MockServer::start().await;
376 Mock::given(method("POST"))
377 .and(path("/login/oauth/access_token"))
378 .and(header("accept", "application/json"))
379 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
380 "access_token": "gho_test-token",
381 "scope": "read:user,user:email",
382 "token_type": "bearer",
383 })))
384 .mount(&server)
385 .await;
386 Mock::given(method("GET"))
387 .and(path("/user"))
388 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
389 "id": 9182310,
390 "login": "octocat",
391 "email": "inline@example.com",
392 })))
393 .mount(&server)
394 .await;
395 Mock::given(method("GET"))
396 .and(path("/user/emails"))
397 .respond_with(ResponseTemplate::new(200).set_body_json(json!([
398 {"email": "unverified@example.com", "primary": true, "verified": false},
399 {"email": "secondary@example.com", "primary": false, "verified": true},
400 ])))
401 .mount(&server)
402 .await;
403
404 let base = url::Url::parse(&server.uri()).unwrap();
405 let provider = test_github_provider().with_endpoints(
406 base.join("login/oauth/authorize").unwrap(),
407 base.join("login/oauth/access_token").unwrap(),
408 base.join("user").unwrap(),
409 base.join("user/emails").unwrap(),
410 );
411
412 let exchange = provider.exchange_code("code", "verifier").await.unwrap();
413 assert_eq!(exchange.subject, "9182310");
414 assert_eq!(exchange.email.as_deref(), Some("inline@example.com"));
415 assert_eq!(exchange.email_verified, None);
416 }
417
418 #[tokio::test]
424 async fn github_exchange_classifies_200_ok_error_body_as_invalid_grant() {
425 let server = MockServer::start().await;
426 Mock::given(method("POST"))
427 .and(path("/login/oauth/access_token"))
428 .and(header("accept", "application/json"))
429 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
430 "error": "bad_verification_code",
431 "error_description": "The code passed is incorrect or expired.",
432 })))
433 .mount(&server)
434 .await;
435
436 let base = url::Url::parse(&server.uri()).unwrap();
437 let provider = test_github_provider().with_endpoints(
438 base.join("login/oauth/authorize").unwrap(),
439 base.join("login/oauth/access_token").unwrap(),
440 base.join("user").unwrap(),
441 base.join("user/emails").unwrap(),
442 );
443
444 let error = provider
445 .exchange_code("code", "verifier")
446 .await
447 .unwrap_err();
448 assert!(
449 matches!(error, AuthError::InvalidGrant(_)),
450 "expected InvalidGrant, got {error:?}"
451 );
452 }
453
454 #[tokio::test]
455 async fn github_refresh_always_errors() {
456 let provider = test_github_provider();
457 let error = provider.refresh("whatever").await.unwrap_err();
458 assert!(error.to_string().contains("do not support token refresh"));
459 }
460
461 #[test]
462 fn github_authorize_url_uses_read_user_and_user_email_scopes() {
463 let provider = test_github_provider();
464 let request = AuthorizeUrlRequest {
465 state: "state-123".to_string(),
466 code_challenge: "challenge".to_string(),
467 code_challenge_method: "S256".to_string(),
468 force_consent: false,
469 };
470 let url = provider.authorize_url(&request).unwrap();
471 assert!(url.as_str().contains("scope=read%3Auser+user%3Aemail"));
472 }
473
474 fn test_github_provider() -> GitHubProvider {
475 GitHubProvider::new(
476 "client-id".to_string(),
477 "client-secret".to_string(),
478 url::Url::parse("https://lab.example.com/auth/github/callback").unwrap(),
479 )
480 .unwrap()
481 }
482}