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,
+ })
+}