Skip to main content

soma_auth/
github.rs

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/// GitHub's token endpoint documented behavior: on an invalid, expired, or
49/// already-used authorization code it returns **HTTP 200** with an error
50/// body (`{"error":"bad_verification_code",...}`) instead of a non-2xx
51/// status — so `provider_http::read_json_response`'s `error_for_status()`
52/// never triggers, and we must distinguish success from failure by shape
53/// after the fact via this untagged enum, rather than by status code.
54#[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    /// Fetches `GET /user` and `GET /user/emails` **concurrently** via
136    /// `tokio::try_join!` — they are independent, both authenticated with the
137    /// same bearer token, and running them sequentially (as an earlier draft
138    /// of this plan did) needlessly widens the worst-case timeout envelope: 3
139    /// sequential hops each independently subject to `GITHUB_HTTP_TIMEOUT`
140    /// (30s) can chain up to ~90s before failing, vs Google/Authelia's ~35s
141    /// worst case (30s token exchange + 5s JWKS). Joining the two GETs caps
142    /// GitHub's worst case at ~60s (30s token exchange + max(30s, 30s)).
143    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        // GitHubProvider::exchange_code must never set refresh_token — GitHub
164        // OAuth Apps don't issue one. The real, always-on guard against a
165        // `refresh_tokens` row naming `provider = "github"` reaching
166        // `GitHubProvider::refresh` (which unconditionally errors) lives in
167        // `token::refresh_token_grant`, right before it calls
168        // `provider.refresh(...)` — not here, and not as a `debug_assert!`
169        // (which compiles out of release builds and, being right next to
170        // this hardcoded `None` literal, could only ever catch someone
171        // editing both lines inconsistently in the same change).
172        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    /// Coverage for the fallback branch in `fetch_exchange`: when
370    /// `/user/emails` has no primary+verified entry (here, none of the
371    /// returned addresses are verified), `email_verified` must be `None`
372    /// and `email` must fall back to `/user`'s inline `email` field.
373    #[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    /// Regression test: GitHub's token endpoint returns HTTP 200 with an
419    /// error body (no non-2xx status) on an invalid/expired/reused
420    /// authorization code — `exchange_code` must classify this as
421    /// `AuthError::InvalidGrant`, not fall through to `AuthError::Decode`
422    /// from a failed `GitHubTokenResponse` deserialization.
423    #[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}