ezidam: oauth: added token endpoint, handling "authorization_code" and "refresh_token" grants
This commit is contained in:
parent
dd69fc99ea
commit
fe24825c3b
5 changed files with 402 additions and 1 deletions
|
|
@ -34,4 +34,10 @@ impl App {
|
||||||
pub fn is_archived(&self) -> bool {
|
pub fn is_archived(&self) -> bool {
|
||||||
self.is_archived
|
self.is_archived
|
||||||
}
|
}
|
||||||
|
pub fn is_confidential(&self) -> bool {
|
||||||
|
self.is_confidential
|
||||||
|
}
|
||||||
|
pub fn secret_hashed(&self) -> &str {
|
||||||
|
&self.secret
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -16,4 +16,30 @@ pub struct AuthenticationRequest<'r> {
|
||||||
pub client_id: &'r str,
|
pub client_id: &'r str,
|
||||||
pub redirect_uri: &'r str,
|
pub redirect_uri: &'r str,
|
||||||
pub state: &'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>,
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,19 @@
|
||||||
use authorize::*;
|
use authorize::*;
|
||||||
use redirect::*;
|
use redirect::*;
|
||||||
use rocket::{routes, Route};
|
use rocket::{routes, Route};
|
||||||
|
use token::*;
|
||||||
|
|
||||||
pub mod authorize;
|
pub mod authorize;
|
||||||
pub mod redirect;
|
pub mod redirect;
|
||||||
|
pub mod token;
|
||||||
|
|
||||||
pub fn routes() -> Vec<Route> {
|
pub fn routes() -> Vec<Route> {
|
||||||
routes![
|
routes![
|
||||||
authorize_page,
|
authorize_page,
|
||||||
authorize_form,
|
authorize_form,
|
||||||
authorize_ezidam,
|
authorize_ezidam,
|
||||||
redirect_page
|
redirect_page,
|
||||||
|
request_token,
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -59,6 +59,7 @@ pub async fn authorize_ezidam(mut db: Connection<Database>) -> Result<Redirect>
|
||||||
client_id: app.id().as_ref(),
|
client_id: app.id().as_ref(),
|
||||||
redirect_uri: app.redirect_uri(),
|
redirect_uri: app.redirect_uri(),
|
||||||
state: "TODO",
|
state: "TODO",
|
||||||
|
nonce: None,
|
||||||
};
|
};
|
||||||
Ok(Redirect::to(uri!(authorize_page(auth_request = request))))
|
Ok(Redirect::to(uri!(authorize_page(auth_request = request))))
|
||||||
}
|
}
|
||||||
|
|
|
||||||
365
crates/ezidam/src/routes/oauth/token.rs
Normal file
365
crates/ezidam/src/routes/oauth/token.rs
Normal file
|
|
@ -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 = "<token_request>")]
|
||||||
|
pub async fn request_token(
|
||||||
|
mut db: Connection<Database>,
|
||||||
|
token_request: Form<TokenRequest<'_>>,
|
||||||
|
app_auth: Option<BasicAuth>,
|
||||||
|
ip_address: IpAddr,
|
||||||
|
) -> std::result::Result<TokenResponse, TokenError> {
|
||||||
|
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,
|
||||||
|
})
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue