Skip to main content

codoseo_web/auth/
github.rs

1//! "Sign in with GitHub": the OAuth web flow. A random `state` value in a short-lived cookie
2//! ties the callback to the browser that started it. The account email is the user's primary
3//! verified GitHub email, so a GitHub login and a magic link for that address are one account.
4
5use axum::extract::{Query, State};
6use axum::http::{HeaderMap, header};
7use axum::response::{AppendHeaders, IntoResponse, Redirect, Response};
8use codoseo_store::accounts::{SignIn, SignInOutcome};
9use serde::Deserialize;
10
11use super::magic::signups_closed;
12use super::{email, safe_next, session, signup_policy};
13use crate::error::AppError;
14use crate::state::AppState;
15
16const STATE_COOKIE: &str = "codoseo_oauth";
17const STATE_TTL_SECS: i64 = 600;
18
19#[derive(Deserialize)]
20pub struct StartQuery {
21    next: Option<String>,
22}
23
24fn callback_url(state: &AppState) -> String {
25    let mut u = state.config.base_url.clone();
26    u.set_path("/auth/github/callback");
27    u.to_string()
28}
29
30pub async fn start(
31    State(state): State<AppState>,
32    Query(q): Query<StartQuery>,
33) -> Result<Response, AppError> {
34    let gh = state.config.github.as_ref().ok_or(AppError::NotFound)?;
35    let nonce = session::random_token();
36    let next = safe_next(q.next.as_deref());
37    let mut url = gh.authorize_url.clone();
38    url.query_pairs_mut()
39        .append_pair("client_id", &gh.client_id)
40        .append_pair("redirect_uri", &callback_url(&state))
41        .append_pair("scope", "read:user user:email")
42        .append_pair("state", &nonce);
43    // The cookie carries the nonce and where to go afterwards: `<nonce>.<urlencoded next>`.
44    let cookie = session::set_cookie(
45        STATE_COOKIE,
46        &format!("{nonce}.{}", super::urlencode(next)),
47        STATE_TTL_SECS,
48        state.config.secure_cookies(),
49    );
50    Ok(([(header::SET_COOKIE, cookie)], Redirect::to(url.as_str())).into_response())
51}
52
53#[derive(Deserialize)]
54pub struct CallbackQuery {
55    code: Option<String>,
56    state: Option<String>,
57    error: Option<String>,
58}
59
60#[derive(Deserialize)]
61struct TokenResponse {
62    access_token: Option<String>,
63    error_description: Option<String>,
64}
65
66#[derive(Deserialize)]
67struct GithubUser {
68    id: u64,
69}
70
71#[derive(Deserialize)]
72struct GithubEmail {
73    email: String,
74    primary: bool,
75    verified: bool,
76}
77
78pub async fn callback(
79    State(state): State<AppState>,
80    headers: HeaderMap,
81    Query(q): Query<CallbackQuery>,
82) -> Result<Response, AppError> {
83    let gh = state.config.github.as_ref().ok_or(AppError::NotFound)?;
84    if q.error.is_some() {
85        return Ok(Redirect::to("/login").into_response());
86    }
87    let cookie = session::cookie(&headers, STATE_COOKIE).unwrap_or_default();
88    let (nonce, next) = cookie.split_once('.').unwrap_or(("", ""));
89    let next: String = url::form_urlencoded::parse(format!("n={next}").as_bytes())
90        .find(|(k, _)| k == "n")
91        .map(|(_, v)| v.into_owned())
92        .unwrap_or_default();
93    let sent_state = q.state.unwrap_or_default();
94    if nonce.is_empty() || !constant_time_eq(nonce.as_bytes(), sent_state.as_bytes()) {
95        return Err(AppError::BadRequest(
96            "GitHub sign-in expired. Please try again.".to_owned(),
97        ));
98    }
99    let code = q
100        .code
101        .ok_or_else(|| AppError::BadRequest("GitHub didn't send a code.".to_owned()))?;
102
103    let token: TokenResponse = state
104        .http
105        .post(gh.token_url.clone())
106        .header(header::ACCEPT, "application/json")
107        .form(&[
108            ("client_id", gh.client_id.as_str()),
109            ("client_secret", gh.client_secret.as_str()),
110            ("code", code.as_str()),
111            ("redirect_uri", callback_url(&state).as_str()),
112        ])
113        .send()
114        .await
115        .map_err(AppError::internal)?
116        .json()
117        .await
118        .map_err(AppError::internal)?;
119    let access = token.access_token.ok_or_else(|| {
120        AppError::BadRequest(
121            token
122                .error_description
123                .unwrap_or_else(|| "GitHub refused the sign-in.".to_owned()),
124        )
125    })?;
126
127    let api = |path: &str| {
128        state
129            .http
130            .get(gh.api_url.join(path).expect("static path"))
131            .bearer_auth(&access)
132            .header(header::ACCEPT, "application/vnd.github+json")
133    };
134    let user: GithubUser = api("user")
135        .send()
136        .await
137        .map_err(AppError::internal)?
138        .error_for_status()
139        .map_err(AppError::internal)?
140        .json()
141        .await
142        .map_err(AppError::internal)?;
143    let emails: Vec<GithubEmail> = api("user/emails")
144        .send()
145        .await
146        .map_err(AppError::internal)?
147        .error_for_status()
148        .map_err(AppError::internal)?
149        .json()
150        .await
151        .map_err(AppError::internal)?;
152    let address = emails
153        .iter()
154        .find(|e| e.primary && e.verified)
155        .or_else(|| emails.iter().find(|e| e.verified))
156        .map(|e| e.email.clone())
157        .ok_or_else(|| {
158            AppError::BadRequest(
159                "Your GitHub account has no verified email address. Use an email link instead."
160                    .to_owned(),
161            )
162        })?;
163
164    let github_id = user.id.to_string();
165    let canonical = email::canonical(&address);
166    let outcome = codoseo_store::accounts::sign_in(
167        &state.pool,
168        &SignIn {
169            email: &address,
170            canonical: &canonical,
171            github_id: Some(&github_id),
172        },
173        signup_policy(&state),
174    )
175    .await?;
176    let account = match outcome {
177        SignInOutcome::Existing(a) | SignInOutcome::Created(a) => a,
178        SignInOutcome::SignupsClosed => return Err(signups_closed()),
179    };
180    let session_cookie = session::start(&state, account.id).await?;
181    let clear = session::set_cookie(STATE_COOKIE, "", 0, state.config.secure_cookies());
182    // Two Set-Cookie headers: an array of pairs would insert, keeping only the last one.
183    Ok((
184        AppendHeaders([
185            (header::SET_COOKIE, session_cookie),
186            (header::SET_COOKIE, clear),
187        ]),
188        Redirect::to(safe_next(Some(&next))),
189    )
190        .into_response())
191}
192
193fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
194    a.len() == b.len() && a.iter().zip(b).fold(0u8, |acc, (x, y)| acc | (x ^ y)) == 0
195}