codoseo_web/auth/
github.rs1use 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 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 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}