1use axum::{
2 extract::{Query, State},
3 http::{StatusCode, header},
4 response::{IntoResponse, Redirect, Response},
5};
6use serde::{Deserialize, Serialize};
7use tower_sessions::Session;
8use tracing::{error, info};
9
10use crate::error::{ApiError, ApiResult};
11use crate::state::OAuthConfig;
12
13const SESSION_USER_KEY: &str = "github_user";
14const SESSION_CSRF_KEY: &str = "oauth_csrf";
15
16#[derive(Debug, Clone, Serialize, Deserialize)]
18pub struct GithubUser {
19 pub id: i64,
20 pub login: String,
21 pub name: Option<String>,
22 pub email: Option<String>,
23}
24
25#[derive(Debug, Deserialize)]
27pub struct AuthCallbackParams {
28 code: String,
29 state: String,
30}
31
32fn generate_csrf_token() -> String {
34 use rand::Rng;
35 let random_bytes: Vec<u8> = (0..32).map(|_| rand::rng().random()).collect();
36 hex::encode(random_bytes)
37}
38
39pub async fn github_auth(
41 State(config): State<OAuthConfig>,
42 session: Session,
43) -> ApiResult<Response> {
44 let csrf_token = generate_csrf_token();
45
46 session
48 .insert(SESSION_CSRF_KEY, csrf_token.clone())
49 .await
50 .map_err(|e| ApiError::InternalError(format!("Session error: {}", e)))?;
51
52 let auth_url = format!(
54 "https://github.com/login/oauth/authorize?client_id={}&redirect_uri={}&scope={}&state={}",
55 config.client_id,
56 urlencoding::encode(&config.redirect_url),
57 "read:user user:email read:org",
58 csrf_token
59 );
60
61 info!("Redirecting to GitHub OAuth: {}", auth_url);
62
63 Ok(Redirect::temporary(&auth_url).into_response())
64}
65
66pub async fn github_callback(
68 State(config): State<OAuthConfig>,
69 Query(params): Query<AuthCallbackParams>,
70 session: Session,
71) -> ApiResult<Response> {
72 let stored_csrf: Option<String> = session
74 .get(SESSION_CSRF_KEY)
75 .await
76 .map_err(|e| ApiError::InternalError(format!("Session error: {}", e)))?;
77
78 let stored_csrf = stored_csrf.ok_or_else(|| {
79 ApiError::Unauthorized("Invalid OAuth state: no CSRF token in session".to_string())
80 })?;
81
82 if stored_csrf != params.state {
83 return Err(ApiError::Unauthorized(
84 "Invalid OAuth state: CSRF mismatch".to_string(),
85 ));
86 }
87
88 let access_token = exchange_code_for_token(&config, ¶ms.code).await?;
90
91 let github_user = fetch_github_user(&access_token).await?;
93
94 info!(
95 "User authenticated: {} (ID: {})",
96 github_user.login, github_user.id
97 );
98
99 session
101 .insert(SESSION_USER_KEY, github_user)
102 .await
103 .map_err(|e| ApiError::InternalError(format!("Session error: {}", e)))?;
104
105 session.remove::<String>(SESSION_CSRF_KEY).await.ok();
107
108 Ok(Redirect::to("/").into_response())
110}
111
112async fn exchange_code_for_token(config: &OAuthConfig, code: &str) -> ApiResult<String> {
114 let client = reqwest::Client::new();
116
117 let body_str = format!(
118 "client_id={}&client_secret={}&code={}",
119 config.client_id, config.client_secret, code
120 );
121
122 let response = client
123 .post("https://github.com/login/oauth/access_token")
124 .header(header::ACCEPT, "application/json")
125 .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
126 .body(body_str)
127 .send()
128 .await
129 .map_err(|e| {
130 error!("Failed to exchange code for token: {}", e);
131 ApiError::InternalError(format!("GitHub OAuth error: {}", e))
132 })?;
133
134 if !response.status().is_success() {
135 let status = response.status();
136 let body = response.text().await.unwrap_or_default();
137 error!("GitHub OAuth error: {} - {}", status, body);
138 return Err(ApiError::Unauthorized(format!(
139 "GitHub OAuth returned error: {}",
140 status
141 )));
142 }
143
144 #[derive(Deserialize)]
145 struct TokenResponse {
146 access_token: String,
147 }
148
149 let token_response: TokenResponse = response.json().await.map_err(|e| {
150 error!("Failed to parse token response: {}", e);
151 ApiError::InternalError(format!("Failed to parse OAuth response: {}", e))
152 })?;
153
154 Ok(token_response.access_token)
155}
156
157async fn fetch_github_user(access_token: &str) -> ApiResult<GithubUser> {
159 let client = reqwest::Client::new();
160
161 let response = client
162 .get("https://api.github.com/user")
163 .header(header::AUTHORIZATION, format!("Bearer {}", access_token))
164 .header(header::USER_AGENT, "meritocrab-app")
165 .send()
166 .await
167 .map_err(|e| {
168 error!("Failed to fetch GitHub user: {}", e);
169 ApiError::InternalError(format!("GitHub API error: {}", e))
170 })?;
171
172 if !response.status().is_success() {
173 let status = response.status();
174 let body = response.text().await.unwrap_or_default();
175 error!("GitHub API error: {} - {}", status, body);
176 return Err(ApiError::InternalError(format!(
177 "GitHub API returned error: {}",
178 status
179 )));
180 }
181
182 let user: GithubUser = response.json().await.map_err(|e| {
183 error!("Failed to parse GitHub user response: {}", e);
184 ApiError::InternalError(format!("Failed to parse GitHub user: {}", e))
185 })?;
186
187 Ok(user)
188}
189
190pub async fn get_session_user(session: &Session) -> ApiResult<GithubUser> {
192 let user: Option<GithubUser> = session
193 .get(SESSION_USER_KEY)
194 .await
195 .map_err(|e| ApiError::InternalError(format!("Session error: {}", e)))?;
196
197 user.ok_or_else(|| ApiError::Unauthorized("Not authenticated".to_string()))
198}
199
200pub async fn logout(session: Session) -> ApiResult<Response> {
202 session.delete().await.map_err(|e| {
203 error!("Failed to delete session: {}", e);
204 ApiError::InternalError(format!("Session error: {}", e))
205 })?;
206
207 Ok((StatusCode::OK, "Logged out").into_response())
208}