Skip to main content

meritocrab_api/
oauth.rs

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/// GitHub user information from OAuth
17#[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/// OAuth callback query parameters
26#[derive(Debug, Deserialize)]
27pub struct AuthCallbackParams {
28    code: String,
29    state: String,
30}
31
32/// Generate a random CSRF token
33fn 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
39/// GET /auth/github - Redirect to GitHub OAuth
40pub async fn github_auth(
41    State(config): State<OAuthConfig>,
42    session: Session,
43) -> ApiResult<Response> {
44    let csrf_token = generate_csrf_token();
45
46    // Store CSRF token in session
47    session
48        .insert(SESSION_CSRF_KEY, csrf_token.clone())
49        .await
50        .map_err(|e| ApiError::InternalError(format!("Session error: {}", e)))?;
51
52    // Build GitHub OAuth URL manually
53    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
66/// GET /auth/callback - Handle GitHub OAuth callback
67pub async fn github_callback(
68    State(config): State<OAuthConfig>,
69    Query(params): Query<AuthCallbackParams>,
70    session: Session,
71) -> ApiResult<Response> {
72    // Verify CSRF token
73    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    // Exchange code for token
89    let access_token = exchange_code_for_token(&config, &params.code).await?;
90
91    // Fetch user info from GitHub API
92    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    // Store user in session
100    session
101        .insert(SESSION_USER_KEY, github_user)
102        .await
103        .map_err(|e| ApiError::InternalError(format!("Session error: {}", e)))?;
104
105    // Remove CSRF token from session
106    session.remove::<String>(SESSION_CSRF_KEY).await.ok();
107
108    // Redirect to dashboard or home
109    Ok(Redirect::to("/").into_response())
110}
111
112/// Exchange authorization code for access token
113async fn exchange_code_for_token(config: &OAuthConfig, code: &str) -> ApiResult<String> {
114    // Make a manual HTTP request to exchange the code for a token
115    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
157/// Fetch GitHub user information using access token
158async 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
190/// Extract authenticated user from session
191pub 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
200/// GET /auth/logout - Log out the user
201pub 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}