iqps_backend/
auth.rs

1//! Utils for Github OAuth integration and JWT authentication
2//!
3//! Currently this is only used in the admin dashboard and uses Github OAuth for authentication
4
5use std::collections::BTreeMap;
6
7use color_eyre::eyre::{eyre, Context, ContextCompat};
8use http::StatusCode;
9use jwt::{Claims, RegisteredClaims, SignWithKey, VerifyWithKey};
10use serde::Deserialize;
11
12use crate::env::EnvVars;
13
14#[derive(Clone)]
15/// Struct containing the auth information of a user
16pub struct Auth {
17    pub jwt: String,
18    pub username: String,
19}
20
21/// Verifies whether a JWT is valid and signed with the secret key
22///
23/// Returns the username and jwt in a struct
24pub async fn verify_token(
25    token: &str,
26    env_vars: &EnvVars,
27) -> Result<Auth, color_eyre::eyre::Error> {
28    let jwt_key = env_vars.get_jwt_key()?;
29    let claims: Result<Claims, _> = token.verify_with_key(&jwt_key);
30
31    let claims = claims.map_err(|_| eyre!("Claims not found on the JWT."))?;
32    let username = claims
33        .private
34        .get("username")
35        .ok_or(eyre!("Username not in the claims."))?;
36    let username = username
37        .as_str()
38        .ok_or(eyre!("Username is not a string."))?;
39
40    Ok(Auth {
41        jwt: token.to_owned(),
42        username: username.to_owned(),
43    })
44}
45
46/// Generates a JWT with the username (for claims) and secret key
47async fn generate_token(
48    username: &str,
49    env_vars: &EnvVars,
50) -> Result<String, color_eyre::eyre::Error> {
51    let jwt_key = env_vars.get_jwt_key()?;
52
53    let expiration = chrono::Utc::now()
54        .checked_add_days(chrono::naive::Days::new(7)) // 7 Days expiration
55        .context("Error: error setting JWT expiry date")?
56        .timestamp()
57        .unsigned_abs();
58
59    let mut private_claims = BTreeMap::new();
60    private_claims.insert(
61        "username".into(),
62        serde_json::Value::String(username.into()),
63    );
64
65    let claims = Claims {
66        registered: RegisteredClaims {
67            audience: None,
68            issued_at: None,
69            issuer: None,
70            subject: None,
71            not_before: None,
72            json_web_token_id: None,
73            expiration: Some(expiration),
74        },
75        private: private_claims,
76    };
77
78    Ok(claims.sign_with_key(&jwt_key)?)
79}
80
81#[derive(Deserialize)]
82struct GithubAccessTokenResponse {
83    access_token: String,
84}
85
86#[derive(Deserialize)]
87struct GithubUserResponse {
88    login: String,
89}
90
91#[derive(Deserialize)]
92struct GithubMembershipResponse {
93    state: String,
94}
95
96/// Takes a Github OAuth code and creates a JWT authentication token for the user
97/// 1. Uses the OAuth code to get an access token.
98/// 2. Uses the access token to get the user's username.
99/// 3. Uses the username and an admin's access token to verify whether the user is a member of the admins github team, or the admin themselves.
100///
101/// Returns the JWT if the user is authenticated, `None` otherwise.
102pub async fn authenticate_user(
103    code: &String,
104    env_vars: &EnvVars,
105) -> Result<Option<String>, color_eyre::eyre::Error> {
106    let client = reqwest::Client::new();
107
108    // Get the access token for authenticating other endpoints
109    let response = client
110        .get(format!(
111            "https://github.com/login/oauth/access_token?client_id={}&client_secret={}&code={}",
112            env_vars.gh_client_id, env_vars.gh_client_secret, code
113        ))
114        .header("Accept", "application/json")
115        .send()
116        .await
117        .context("Error getting access token from Github.")?;
118
119    if response.status() != StatusCode::OK {
120        tracing::error!(
121            "Github OAuth error getting access token: {}",
122            response.text().await?
123        );
124
125        return Err(eyre!("Github API response error."));
126    }
127
128    let access_token =
129        serde_json::from_slice::<GithubAccessTokenResponse>(&response.bytes().await?)
130            .context("Error parsing access token response.")?
131            .access_token;
132
133    // Get the username of the user who made the request
134    let response = client
135        .get("https://api.github.com/user")
136        .header("Authorization", format!("Bearer {}", access_token))
137        .header("User-Agent", "bruh") // Why is this required :ded:
138        .send()
139        .await
140        .context("Error fetching user's username.")?;
141
142    if response.status() != StatusCode::OK {
143        tracing::error!(
144            "Github OAuth error getting username: {}",
145            response.text().await?
146        );
147
148        return Err(eyre!("Github API response error."));
149    }
150
151    let username = serde_json::from_slice::<GithubUserResponse>(&response.bytes().await?)
152        .context("Error parsing username API response.")?
153        .login;
154
155    // Check if the user is in the admin list
156    if env_vars
157        .gh_admin_usernames
158        .split(",")
159        .any(|x| x == username)
160    {
161        return Ok(Some(generate_token(&username, env_vars).await?));
162    }
163
164    // Check the user's membership in the team
165    println!(
166        "https://api.github.com/orgs/{}/teams/{}/memberships/{}",
167        env_vars.gh_org_name, env_vars.gh_org_team_slug, username
168    );
169
170    let response = client
171        .get(format!(
172            "https://api.github.com/orgs/{}/teams/{}/memberships/{}",
173            env_vars.gh_org_name, env_vars.gh_org_team_slug, username
174        ))
175        .header(
176            "Authorization",
177            format!("Bearer {}", env_vars.gh_org_admin_token),
178        )
179        .header("User-Agent", "bruh why is this required")
180        .send()
181        .await
182        .context("Error getting user's team membership")?;
183
184    if response.status() != StatusCode::OK {
185        tracing::error!(
186            "Github OAuth error getting membership status: {}",
187            response.text().await?
188        );
189
190        return Err(eyre!("Github API response error."));
191    }
192
193    let state = serde_json::from_slice::<GithubMembershipResponse>(&response.bytes().await?)
194        .context("Error parsing membership API response.")?
195        .state;
196
197    if state != "active" {
198        Ok(None)
199    } else {
200        Ok(Some(generate_token(&username, env_vars).await?))
201    }
202}