use super::{ db::{self, UserId}, errors::TideResultExt, }; use crate::{github, AppState, Request, RequestExt as _}; use anyhow::{anyhow, Context}; use async_trait::async_trait; pub use oauth2::basic::BasicClient as Client; use oauth2::{ AuthUrl, AuthorizationCode, ClientId, CsrfToken, PkceCodeChallenge, RedirectUrl, TokenResponse as _, TokenUrl, }; use rand::thread_rng; use rpc::auth as zed_auth; use scrypt::{ password_hash::{PasswordHash, PasswordHasher, PasswordVerifier, SaltString}, Scrypt, }; use serde::{Deserialize, Serialize}; use std::{borrow::Cow, convert::TryFrom, sync::Arc}; use surf::{StatusCode, Url}; use tide::{log, Error, Server}; static CURRENT_GITHUB_USER: &'static str = "current_github_user"; static GITHUB_AUTH_URL: &'static str = "https://github.com/login/oauth/authorize"; static GITHUB_TOKEN_URL: &'static str = "https://github.com/login/oauth/access_token"; #[derive(Serialize)] pub struct User { pub github_login: String, pub avatar_url: String, pub is_insider: bool, pub is_admin: bool, } pub async fn process_auth_header(request: &Request) -> tide::Result { let mut auth_header = request .header("Authorization") .ok_or_else(|| { Error::new( StatusCode::BadRequest, anyhow!("missing authorization header"), ) })? .last() .as_str() .split_whitespace(); let user_id = UserId(auth_header.next().unwrap_or("").parse().map_err(|_| { Error::new( StatusCode::BadRequest, anyhow!("missing user id in authorization header"), ) })?); let access_token = auth_header.next().ok_or_else(|| { Error::new( StatusCode::BadRequest, anyhow!("missing access token in authorization header"), ) })?; let state = request.state().clone(); let mut credentials_valid = false; for password_hash in state.db.get_access_token_hashes(user_id).await? { if verify_access_token(&access_token, &password_hash)? { credentials_valid = true; break; } } if !credentials_valid { Err(Error::new( StatusCode::Unauthorized, anyhow!("invalid credentials"), ))?; } Ok(user_id) } #[async_trait] pub trait RequestExt { async fn current_user(&self) -> tide::Result>; } #[async_trait] impl RequestExt for Request { async fn current_user(&self) -> tide::Result> { if let Some(details) = self.session().get::(CURRENT_GITHUB_USER) { let user = self.db().get_user_by_github_login(&details.login).await?; Ok(Some(User { github_login: details.login, avatar_url: details.avatar_url, is_insider: user.is_some(), is_admin: user.map_or(false, |user| user.admin), })) } else { Ok(None) } } } pub fn build_client(client_id: &str, client_secret: &str) -> Client { Client::new( ClientId::new(client_id.to_string()), Some(oauth2::ClientSecret::new(client_secret.to_string())), AuthUrl::new(GITHUB_AUTH_URL.into()).unwrap(), Some(TokenUrl::new(GITHUB_TOKEN_URL.into()).unwrap()), ) } pub fn add_routes(app: &mut Server>) { app.at("/sign_in").get(get_sign_in); app.at("/sign_out").post(post_sign_out); app.at("/auth_callback").get(get_auth_callback); app.at("/native_app_signin").get(get_sign_in); app.at("/native_app_signin_succeeded") .get(get_app_signin_success); } #[derive(Debug, Deserialize)] struct NativeAppSignInParams { native_app_port: String, native_app_public_key: String, impersonate: Option, } async fn get_sign_in(mut request: Request) -> tide::Result { let (pkce_challenge, pkce_verifier) = PkceCodeChallenge::new_random_sha256(); request .session_mut() .insert("pkce_verifier", pkce_verifier)?; let mut redirect_url = Url::parse(&format!( "{}://{}/auth_callback", request .header("X-Forwarded-Proto") .and_then(|values| values.get(0)) .map(|value| value.as_str()) .unwrap_or("http"), request.host().unwrap() ))?; let app_sign_in_params: Option = request.query().ok(); if let Some(query) = app_sign_in_params { let mut redirect_query = redirect_url.query_pairs_mut(); redirect_query .clear() .append_pair("native_app_port", &query.native_app_port) .append_pair("native_app_public_key", &query.native_app_public_key); if let Some(impersonate) = &query.impersonate { redirect_query.append_pair("impersonate", impersonate); } } let (auth_url, csrf_token) = request .state() .auth_client .authorize_url(CsrfToken::new_random) .set_redirect_uri(Cow::Owned(RedirectUrl::from_url(redirect_url))) .set_pkce_challenge(pkce_challenge) .url(); request .session_mut() .insert("auth_csrf_token", csrf_token)?; Ok(tide::Redirect::new(auth_url).into()) } async fn get_app_signin_success(_: Request) -> tide::Result { Ok(tide::Redirect::new("/").into()) } async fn get_auth_callback(mut request: Request) -> tide::Result { #[derive(Debug, Deserialize)] struct Query { code: String, state: String, #[serde(flatten)] native_app_sign_in_params: Option, } let query: Query = request.query()?; let pkce_verifier = request .session() .get("pkce_verifier") .ok_or_else(|| anyhow!("could not retrieve pkce_verifier from session"))?; let csrf_token = request .session() .get::("auth_csrf_token") .ok_or_else(|| anyhow!("could not retrieve auth_csrf_token from session"))?; if &query.state != csrf_token.secret() { return Err(anyhow!("csrf token does not match").into()); } let github_access_token = request .state() .auth_client .exchange_code(AuthorizationCode::new(query.code)) .set_pkce_verifier(pkce_verifier) .request_async(oauth2_surf::http_client) .await .context("failed to exchange oauth code")? .access_token() .secret() .clone(); let user_details = request .state() .github_client .user(github_access_token) .details() .await .context("failed to fetch user")?; let user = request .db() .get_user_by_github_login(&user_details.login) .await?; request .session_mut() .insert(CURRENT_GITHUB_USER, user_details.clone())?; // When signing in from the native app, generate a new access token for the current user. Return // a redirect so that the user's browser sends this access token to the locally-running app. if let Some((user, app_sign_in_params)) = user.zip(query.native_app_sign_in_params) { let mut user_id = user.id; if let Some(impersonated_login) = app_sign_in_params.impersonate { log::info!("attempting to impersonate user @{}", impersonated_login); if let Some(user) = request.db().get_users_by_ids([user_id]).await?.first() { if user.admin { user_id = request.db().create_user(&impersonated_login, false).await?; log::info!("impersonating user {}", user_id.0); } else { log::info!("refusing to impersonate user"); } } } let access_token = create_access_token(request.db(), user_id).await?; let encrypted_access_token = encrypt_access_token( &access_token, app_sign_in_params.native_app_public_key.clone(), )?; return Ok(tide::Redirect::new(&format!( "http://127.0.0.1:{}?user_id={}&access_token={}", app_sign_in_params.native_app_port, user_id.0, encrypted_access_token, )) .into()); } Ok(tide::Redirect::new("/").into()) } async fn post_sign_out(mut request: Request) -> tide::Result { request.session_mut().remove(CURRENT_GITHUB_USER); Ok(tide::Redirect::new("/").into()) } const MAX_ACCESS_TOKENS_TO_STORE: usize = 8; pub async fn create_access_token(db: &db::Db, user_id: UserId) -> tide::Result { let access_token = zed_auth::random_token(); let access_token_hash = hash_access_token(&access_token).context("failed to hash access token")?; db.create_access_token_hash(user_id, &access_token_hash, MAX_ACCESS_TOKENS_TO_STORE) .await?; Ok(access_token) } fn hash_access_token(token: &str) -> tide::Result { // Avoid slow hashing in debug mode. let params = if cfg!(debug_assertions) { scrypt::Params::new(1, 1, 1).unwrap() } else { scrypt::Params::recommended() }; Ok(Scrypt .hash_password( token.as_bytes(), None, params, &SaltString::generate(thread_rng()), )? .to_string()) } pub fn encrypt_access_token(access_token: &str, public_key: String) -> tide::Result { let native_app_public_key = zed_auth::PublicKey::try_from(public_key).context("failed to parse app public key")?; let encrypted_access_token = native_app_public_key .encrypt_string(&access_token) .context("failed to encrypt access token with public key")?; Ok(encrypted_access_token) } pub fn verify_access_token(token: &str, hash: &str) -> tide::Result { let hash = PasswordHash::new(hash)?; Ok(Scrypt.verify_password(token.as_bytes(), &hash).is_ok()) }