From fe24825c3bcebe9c25a9ed5bd4d5247a2a86a029 Mon Sep 17 00:00:00 2001 From: Philippe Loctaux Date: Tue, 4 Apr 2023 00:03:41 +0200 Subject: [PATCH] ezidam: oauth: added token endpoint, handling "authorization_code" and "refresh_token" grants --- crates/apps/src/lib.rs | 6 + crates/ezidam/src/oauth.rs | 26 ++ crates/ezidam/src/routes/oauth.rs | 5 +- crates/ezidam/src/routes/oauth/authorize.rs | 1 + crates/ezidam/src/routes/oauth/token.rs | 365 ++++++++++++++++++++ 5 files changed, 402 insertions(+), 1 deletion(-) create mode 100644 crates/ezidam/src/routes/oauth/token.rs diff --git a/crates/apps/src/lib.rs b/crates/apps/src/lib.rs index a0bb7a0..adfb996 100644 --- a/crates/apps/src/lib.rs +++ b/crates/apps/src/lib.rs @@ -34,4 +34,10 @@ impl App { pub fn is_archived(&self) -> bool { self.is_archived } + pub fn is_confidential(&self) -> bool { + self.is_confidential + } + pub fn secret_hashed(&self) -> &str { + &self.secret + } } diff --git a/crates/ezidam/src/oauth.rs b/crates/ezidam/src/oauth.rs index 61a204a..1230838 100644 --- a/crates/ezidam/src/oauth.rs +++ b/crates/ezidam/src/oauth.rs @@ -16,4 +16,30 @@ pub struct AuthenticationRequest<'r> { pub client_id: &'r str, pub redirect_uri: &'r str, pub state: &'r str, + pub nonce: Option<&'r str>, +} + +#[derive(Debug, FromForm, UriDisplayQuery)] +pub struct RedirectRequest<'r> { + pub code: &'r str, + pub state: &'r str, +} + +#[derive(Debug, FromFormField, UriDisplayQuery)] +pub enum GrantType { + #[field(value = "authorization_code")] + AuthorizationCode, + #[field(value = "refresh_token")] + RefreshToken, +} + +#[derive(Debug, FromForm, UriDisplayQuery)] +pub struct TokenRequest<'r> { + pub grant_type: GrantType, + pub code: Option<&'r str>, + pub redirect_uri: Option<&'r str>, + pub client_id: &'r str, + pub client_secret: Option<&'r str>, + pub scope: Option<&'r str>, + pub refresh_token: Option<&'r str>, } diff --git a/crates/ezidam/src/routes/oauth.rs b/crates/ezidam/src/routes/oauth.rs index 8cc7f2b..fee1374 100644 --- a/crates/ezidam/src/routes/oauth.rs +++ b/crates/ezidam/src/routes/oauth.rs @@ -1,16 +1,19 @@ use authorize::*; use redirect::*; use rocket::{routes, Route}; +use token::*; pub mod authorize; pub mod redirect; +pub mod token; pub fn routes() -> Vec { routes![ authorize_page, authorize_form, authorize_ezidam, - redirect_page + redirect_page, + request_token, ] } diff --git a/crates/ezidam/src/routes/oauth/authorize.rs b/crates/ezidam/src/routes/oauth/authorize.rs index 7f1646b..0430d84 100644 --- a/crates/ezidam/src/routes/oauth/authorize.rs +++ b/crates/ezidam/src/routes/oauth/authorize.rs @@ -59,6 +59,7 @@ pub async fn authorize_ezidam(mut db: Connection) -> Result client_id: app.id().as_ref(), redirect_uri: app.redirect_uri(), state: "TODO", + nonce: None, }; Ok(Redirect::to(uri!(authorize_page(auth_request = request)))) } diff --git a/crates/ezidam/src/routes/oauth/token.rs b/crates/ezidam/src/routes/oauth/token.rs new file mode 100644 index 0000000..04be7a4 --- /dev/null +++ b/crates/ezidam/src/routes/oauth/token.rs @@ -0,0 +1,365 @@ +use crate::routes::prelude::*; +use crate::tokens::{generate_jwt, generate_refresh_token, JWT_DURATION_MINUTES}; +use apps::App; +use authorization_codes::AuthorizationCode; +use hash::Secret; +use jwt::database::Key; +use jwt::PrivateKey; +use refresh_tokens::RefreshToken; +use rocket::http::{Header, Status}; +use rocket::response::Responder; +use rocket::serde::json::serde_json::json; +use rocket::serde::json::Json; +use rocket::serde::Serialize; +use rocket::time::Duration; +use rocket::{post, response, Request, Response}; +use settings::Settings; +use std::net::IpAddr; +use users::User; + +#[derive(Serialize)] +#[serde(crate = "rocket::serde")] +pub struct TokenResponse { + pub access_token: String, + pub token_type: &'static str, + pub refresh_token: String, + pub expires_in: i64, + pub id_token: String, +} + +impl<'r> Responder<'r, 'static> for TokenResponse { + fn respond_to(self, req: &'r Request<'_>) -> response::Result<'static> { + Response::build_from(Json(&self).respond_to(req)?) + .header(Header::new("Cache-Control", "no-store")) + .header(Header::new("Pragma", "no-cache")) + .ok() + } +} + +pub enum TokenError { + TransactionStart(rocket_db_pools::sqlx::Error), + TransactionCommit(rocket_db_pools::sqlx::Error), + AuthorizationCodeNotProvided, + RefreshTokenNotProvided, + AuthorizationError(authorization_codes::Error), + AuthorizationCodeNotFound(String), + RefreshTokenError(refresh_tokens::Error), + RefreshTokenNotFound(String), + RefreshTokenUsed, + RefreshTokenRevoked, + RefreshTokenExpired, + AuthorizationCodeUsed, + AuthorizationCodeExpired, + HttpAuthDifferentClientId, + AppError(apps::Error), + AppNotFound(String), + AppSecretNotProvided, + Blocking(task::JoinError), + SecretCompare(hash::Error), + AppSecretWrong, + UserError(users::Error), + UserNotFound, + UserArchived(String), + SettingsError(settings::Error), + ServerUrlNotSet, + RefreshTokenGenerate(String), + JwtError(jwt::Error), + JwkNotFound, + JwkRevoked(String), + JwkImport(jwt::Error), + JwtGenerate(String), +} + +impl<'r> Responder<'r, 'static> for TokenError { + fn respond_to(self, req: &'r Request<'_>) -> response::Result<'static> { + let (status, data) = match self { + TokenError::TransactionStart(e) => (Status::InternalServerError, e.to_string()), + TokenError::TransactionCommit(e) => (Status::InternalServerError, e.to_string()), + TokenError::AuthorizationCodeNotProvided => ( + Status::BadRequest, + "Authorization code is not provided".to_string(), + ), + TokenError::RefreshTokenNotProvided => ( + Status::BadRequest, + "Refresh token is not provided".to_string(), + ), + TokenError::AuthorizationError(e) => (Status::InternalServerError, e.to_string()), + TokenError::AuthorizationCodeNotFound(e) => ( + Status::NotFound, + format!("Could not find authorization code {e}"), + ), + TokenError::RefreshTokenError(e) => (Status::InternalServerError, e.to_string()), + TokenError::RefreshTokenNotFound(e) => { + (Status::NotFound, format!("Refresh token {e} was not found")) + } + TokenError::RefreshTokenUsed => { + (Status::BadRequest, "Refresh token has expired".to_string()) + } + TokenError::RefreshTokenRevoked => ( + Status::BadRequest, + "Refresh token has been revoked".to_string(), + ), + TokenError::RefreshTokenExpired => { + (Status::BadRequest, "Refresh token has expired".to_string()) + } + TokenError::AuthorizationCodeUsed => ( + Status::BadRequest, + "Authorization code has already been used".to_string(), + ), + TokenError::AuthorizationCodeExpired => ( + Status::BadRequest, + "Authorization code has expired".to_string(), + ), + TokenError::HttpAuthDifferentClientId => ( + Status::BadRequest, + "HTTP Auth differs from provided client_id".to_string(), + ), + TokenError::AppError(e) => (Status::InternalServerError, e.to_string()), + TokenError::AppNotFound(e) => { + (Status::NotFound, format!("Could not find application {e}")) + } + TokenError::AppSecretNotProvided => { + (Status::BadRequest, "Secret was not provided".to_string()) + } + TokenError::Blocking(e) => (Status::InternalServerError, e.to_string()), + TokenError::SecretCompare(e) => ( + Status::InternalServerError, + format!("Failed to check app secret: {e}"), + ), + TokenError::AppSecretWrong => { + (Status::Forbidden, "Invalid secret provided".to_string()) + } + TokenError::UserError(e) => (Status::InternalServerError, e.to_string()), + TokenError::UserNotFound => ( + Status::NotFound, + "Could not find user from authorization code".to_string(), + ), + TokenError::UserArchived(e) => (Status::Forbidden, format!("User {e} is archived")), + TokenError::SettingsError(e) => (Status::InternalServerError, e.to_string()), + TokenError::ServerUrlNotSet => { + (Status::BadRequest, "Server url is not set".to_string()) + } + TokenError::RefreshTokenGenerate(e) => (Status::InternalServerError, e), + TokenError::JwtError(e) => (Status::InternalServerError, e.to_string()), + TokenError::JwkNotFound => ( + Status::InternalServerError, + "Failed to get key to sign JWT".to_string(), + ), + TokenError::JwkRevoked(e) => ( + Status::InternalServerError, + format!("Signing key {e} has been revoked"), + ), + TokenError::JwkImport(e) => (Status::InternalServerError, e.to_string()), + TokenError::JwtGenerate(e) => (Status::InternalServerError, e), + }; + + Response::build_from(json!({ "error": data }).respond_to(req)?) + .status(status) + .header(Header::new("Cache-Control", "no-store")) + .header(Header::new("Pragma", "no-cache")) + .ok() + } +} + +#[post("/oauth/token", data = "")] +pub async fn request_token( + mut db: Connection, + token_request: Form>, + app_auth: Option, + ip_address: IpAddr, +) -> std::result::Result { + let mut transaction = db.begin().await.map_err(TokenError::TransactionStart)?; + + // Get user depending on grant type + let user = match token_request.grant_type { + GrantType::AuthorizationCode => { + let authorization_code = token_request + .code + .ok_or(TokenError::AuthorizationCodeNotProvided)?; + + // Get authorization code + let code = AuthorizationCode::get_one(&mut transaction, authorization_code) + .await + .map_err(TokenError::AuthorizationError)? + .ok_or(TokenError::AuthorizationCodeNotFound( + authorization_code.into(), + ))?; + + // Make sure code has not been used + if code.has_been_used() { + // Revoke all codes and refresh tokens for user + code.use_all_for_user(&mut transaction) + .await + .map_err(TokenError::AuthorizationError)?; + RefreshToken::revoke_all_for_user(&mut transaction, code.user()) + .await + .map_err(TokenError::RefreshTokenError)?; + + transaction + .commit() + .await + .map_err(TokenError::TransactionCommit)?; + + return Err(TokenError::AuthorizationCodeUsed); + } + + // Make sure it has not expired + if code.has_expired() { + return Err(TokenError::AuthorizationCodeExpired); + } + + // Get user info + let user = User::get_one_from_authorization_code(&mut transaction, authorization_code) + .await + .map_err(TokenError::UserError)? + .ok_or(TokenError::UserNotFound)?; + + // Check if user is archived + if user.is_archived() { + return Err(TokenError::UserArchived(user.id().to_string())); + } + + // Mark code as used + code.use_code(&mut transaction) + .await + .map_err(TokenError::AuthorizationError)?; + + user + } + GrantType::RefreshToken => { + let refresh_token = token_request + .refresh_token + .ok_or(TokenError::RefreshTokenNotProvided)?; + + let user = User::get_one_from_refresh_token(&mut transaction, refresh_token) + .await + .map_err(TokenError::UserError)? + .ok_or(TokenError::UserNotFound)?; + + let refresh_token = RefreshToken::get_one(&mut transaction, refresh_token) + .await + .map_err(TokenError::RefreshTokenError)? + .ok_or(TokenError::RefreshTokenNotFound(refresh_token.into()))?; + + if refresh_token.has_been_used() { + // Revoke all tokens for user + RefreshToken::revoke_all_for_user(&mut transaction, refresh_token.user()) + .await + .map_err(TokenError::RefreshTokenError)?; + + transaction + .commit() + .await + .map_err(TokenError::TransactionCommit)?; + + return Err(TokenError::RefreshTokenUsed); + } + + if refresh_token.is_revoked() { + return Err(TokenError::RefreshTokenRevoked); + } + + if refresh_token.has_expired() { + return Err(TokenError::RefreshTokenExpired); + } + + refresh_token + .use_token(&mut transaction) + .await + .map_err(TokenError::RefreshTokenError)?; + + user + } + }; + + // If HTTP Basic Auth is provided, verify provided client id in form + if let Some(app_auth) = &app_auth { + if app_auth.id != token_request.client_id { + return Err(TokenError::HttpAuthDifferentClientId); + } + } + + // Get app + let app = App::get_one_by_id(&mut transaction, token_request.client_id) + .await + .map_err(TokenError::AppError)? + .ok_or(TokenError::AppNotFound(token_request.client_id.into()))?; + + if app.is_confidential() { + let provided_secret = match (app_auth, token_request.client_secret) { + (Some(http_auth), _) => http_auth.password, + (None, Some(form)) => form.into(), + (None, None) => { + return Err(TokenError::AppSecretNotProvided); + } + }; + + // Get secret (can't use Secret struct directly because of non-async `compare`) + let secret = Secret::from_hash(app.secret_hashed()); + + // Verify secret + if !task::spawn_blocking(move || secret.compare(&provided_secret)) + .await + .map_err(TokenError::Blocking)? + .map_err(TokenError::SecretCompare)? + { + return Err(TokenError::AppSecretWrong); + } + } + + // Get base url + let settings = Settings::get(&mut transaction) + .await + .map_err(TokenError::SettingsError)?; + let home_page = settings + .url() + .map(String::from) + .ok_or(TokenError::ServerUrlNotSet)?; + + // Generate refresh token + let refresh_token = generate_refresh_token(&mut transaction, ip_address, user.id(), app.id()) + .await + .map_err(TokenError::RefreshTokenGenerate)?; + + // Get latest key from database + let key = Key::get_most_recent(&mut transaction) + .await + .map_err(TokenError::JwtError)? + .ok_or(TokenError::JwkNotFound)?; + + // Make sure key has not been revoked + if key.is_revoked() { + return Err(TokenError::JwkRevoked(key.key_id().to_string())); + } + + // Import private key + let private_key = + task::spawn_blocking(move || PrivateKey::from_der(key.private_der(), key.key_id())) + .await + .map_err(TokenError::Blocking)? + .map_err(TokenError::JwkImport)?; + + // Generate jwt + let jwt = generate_jwt( + &mut transaction, + &private_key, + &home_page, + &app.id().0, + &user, + ) + .await + .map_err(TokenError::JwtGenerate)?; + + transaction + .commit() + .await + .map_err(TokenError::TransactionCommit)?; + + Ok(TokenResponse { + access_token: jwt.clone(), + token_type: "Bearer", + refresh_token, + expires_in: Duration::minutes(JWT_DURATION_MINUTES).whole_minutes(), + id_token: jwt, + }) +}