Skip to main content

modo/auth/oauth/
github.rs

1use axum_extra::extract::cookie::Key;
2
3use crate::cookie::CookieConfig;
4
5use super::{
6    client,
7    config::{CallbackParams, OAuthProviderConfig},
8    profile::UserProfile,
9    provider::OAuthProvider,
10    state::{AuthorizationRequest, OAuthState, build_oauth_cookie, pkce_challenge},
11};
12
13const AUTHORIZE_URL: &str = "https://github.com/login/oauth/authorize";
14const TOKEN_URL: &str = "https://github.com/login/oauth/access_token";
15const USER_URL: &str = "https://api.github.com/user";
16const EMAILS_URL: &str = "https://api.github.com/user/emails";
17const DEFAULT_SCOPES: &[&str] = &["user:email", "read:user"];
18
19/// OAuth 2.0 provider implementation for GitHub.
20///
21/// Implements the Authorization Code flow with PKCE (S256). Default scopes are
22/// `user:email` and `read:user`. Override them via
23/// [`OAuthProviderConfig::scopes`](super::OAuthProviderConfig::scopes).
24///
25/// The primary verified email is fetched from the GitHub `/user/emails` endpoint and used to
26/// populate [`UserProfile::email`](super::UserProfile::email) and
27/// [`UserProfile::email_verified`](super::UserProfile::email_verified).
28pub struct GitHub {
29    config: OAuthProviderConfig,
30    cookie_config: CookieConfig,
31    key: Key,
32    http_client: reqwest::Client,
33}
34
35impl GitHub {
36    /// Creates a new `GitHub` provider from the given configuration.
37    ///
38    /// `cookie_config` and `key` are used to sign the `_oauth_state` cookie that carries the
39    /// PKCE verifier and state nonce across the redirect. `http_client` is a
40    /// [`reqwest::Client`] used for the token exchange and user-info API calls.
41    pub fn new(
42        config: &OAuthProviderConfig,
43        cookie_config: &CookieConfig,
44        key: &Key,
45        http_client: reqwest::Client,
46    ) -> Self {
47        Self {
48            config: config.clone(),
49            cookie_config: cookie_config.clone(),
50            key: key.clone(),
51            http_client,
52        }
53    }
54
55    fn scopes(&self) -> String {
56        if self.config.scopes.is_empty() {
57            DEFAULT_SCOPES.join(" ")
58        } else {
59            self.config.scopes.join(" ")
60        }
61    }
62}
63
64impl OAuthProvider for GitHub {
65    fn name(&self) -> &str {
66        "github"
67    }
68
69    fn authorize_url(&self) -> crate::Result<AuthorizationRequest> {
70        let (set_cookie_header, state_nonce, pkce_verifier) =
71            build_oauth_cookie("github", &self.key, &self.cookie_config);
72
73        let challenge = pkce_challenge(&pkce_verifier);
74
75        let redirect_url = format!(
76            "{AUTHORIZE_URL}?response_type=code&client_id={}&redirect_uri={}&scope={}&state={}&code_challenge={}&code_challenge_method=S256",
77            urlencoding::encode(&self.config.client_id),
78            urlencoding::encode(&self.config.redirect_uri),
79            urlencoding::encode(&self.scopes()),
80            urlencoding::encode(&state_nonce),
81            urlencoding::encode(&challenge),
82        );
83
84        Ok(AuthorizationRequest {
85            redirect_url,
86            set_cookie_header,
87        })
88    }
89
90    async fn exchange(
91        &self,
92        params: &CallbackParams,
93        state: &OAuthState,
94    ) -> crate::Result<UserProfile> {
95        if state.provider() != "github" {
96            return Err(crate::Error::bad_request("OAuth state provider mismatch"));
97        }
98
99        if params.state != state.state_nonce() {
100            return Err(crate::Error::bad_request("OAuth state nonce mismatch"));
101        }
102
103        #[derive(serde::Deserialize)]
104        struct TokenResponse {
105            access_token: String,
106        }
107
108        let token: TokenResponse = client::post_form(
109            &self.http_client,
110            TOKEN_URL,
111            &[
112                ("client_id", &self.config.client_id),
113                ("client_secret", &self.config.client_secret),
114                ("code", &params.code),
115                ("redirect_uri", &self.config.redirect_uri),
116                ("code_verifier", state.pkce_verifier()),
117            ],
118        )
119        .await?;
120
121        let raw: serde_json::Value =
122            client::get_json(&self.http_client, USER_URL, &token.access_token).await?;
123
124        let provider_user_id = raw["id"]
125            .as_u64()
126            .map(|id| id.to_string())
127            .or_else(|| raw["id"].as_str().map(|s| s.to_string()))
128            .ok_or_else(|| crate::Error::internal("github: missing user id"))?;
129
130        let name = raw["name"].as_str().map(|s| s.to_string());
131        let avatar_url = raw["avatar_url"].as_str().map(|s| s.to_string());
132
133        #[derive(serde::Deserialize)]
134        struct GitHubEmail {
135            email: String,
136            primary: bool,
137            verified: bool,
138        }
139
140        let emails: Vec<GitHubEmail> =
141            client::get_json(&self.http_client, EMAILS_URL, &token.access_token).await?;
142
143        let primary = emails
144            .iter()
145            .find(|e| e.primary)
146            .ok_or_else(|| crate::Error::internal("github: no primary email"))?;
147
148        Ok(UserProfile {
149            provider: "github".to_string(),
150            provider_user_id,
151            email: primary.email.clone(),
152            email_verified: primary.verified,
153            name,
154            avatar_url,
155            raw,
156        })
157    }
158}