modo/auth/oauth/
github.rs1use 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
19pub struct GitHub {
29 config: OAuthProviderConfig,
30 cookie_config: CookieConfig,
31 key: Key,
32 http_client: reqwest::Client,
33}
34
35impl GitHub {
36 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", ¶ms.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}