Compare commits

...
Sign in to create a new pull request.

8 commits

12 changed files with 1195 additions and 464 deletions

1556
Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -50,3 +50,4 @@ redis = { version = "0.27.3", default-features = false, features = ["acl"] }
# Auth utils
totp-rs = "5.6"
shadow-rs = { version = "1.2.0", features = ["metadata", "std"] }

View file

@ -62,3 +62,11 @@
- [ ] implement docker secrets. https://docs.docker.com/engine/swarm/secrets/
- [ ] Find a minimal OpenID client implementation like Listmonk but a little bit more mature
- [x] Add `iat` field to id_token claims. Even though the iat field is not required in the spec,
most OIDC client require its use for security reason, to not accept a token before a certain date.
It make it clear that the token must not be retro-active.
- [ ] Add `picture` claim in `id_token`
> URL of the End-User's profile picture. This URL MUST refer to an image file (for example, a PNG, JPEG, or GIF image file), rather than to a Web page containing an image. Note that this URL SHOULD specifically reference a profile photo of the End-User suitable for displaying when describing the End-User, rather than an arbitrary photo taken by the End-User.

View file

@ -7,6 +7,7 @@ kernel = { path = "../kernel" }
utils = { path = "../utils" }
# common
shadow-rs = { workspace = true }
log = { workspace = true }
env_logger = { workspace = true }
@ -19,23 +20,8 @@ fully_pub = { workspace = true }
tokio = { workspace = true }
# Web
axum = { version = "0.7.7", features = ["json", "multipart"] }
axum-extra = { version = "0.9.4", features = ["cookie"] }
axum-template = { version = "2.4.0", features = ["minijinja"] }
axum_typed_multipart = "0.13.1"
minijinja = { version = "2.1", features = ["builtins"] }
# to make work the static assets server
tower-http = { version = "0.6.1", features = ["fs"] }
minijinja-embed = "2.3.1"
axum-macros = "0.4.2"
jsonwebtoken = "9.3.0"
time = "0.3.36"
serde = { workspace = true }
serde_json = { workspace = true }
serde_urlencoded = "0.7.1"
chrono = { workspace = true }
argh = { workspace = true }
@ -44,6 +30,22 @@ sqlx = { workspace = true }
uuid = { workspace = true }
url = { workspace = true }
# Web
axum = { version = "0.8.4", features = ["json", "multipart"] }
axum-extra = { version = "0.10.0", features = ["cookie"] }
axum-template = { version = "2.4.0", features = ["minijinja"] }
axum_typed_multipart = "0.16.2"
# to make work the static assets server
tower-http = { version = "0.6.1", features = ["fs", "trace"] }
# template engine
minijinja = { version = "2.1", features = ["builtins"] }
minijinja-embed = "2.3.1"
axum-macros = "0.4.2"
jsonwebtoken = "9.3.0"
time = "0.3.36"
serde_urlencoded = "0.7.1"
# To work with key pair
pem = "3.0.4"
@ -53,6 +55,7 @@ branch = "master"
features = ["simple_asn1", "pem"]
[build-dependencies]
shadow-rs = { workspace = true }
minijinja-embed = "2.3.1"
[[bin]]

View file

@ -1,3 +1,6 @@
use shadow_rs::ShadowBuilder;
fn main() {
ShadowBuilder::builder().build().unwrap();
minijinja_embed::embed_templates!("src/templates");
}

View file

@ -1,8 +1,11 @@
use axum::{extract::State, response::IntoResponse, Json};
use serde_json::json;
use shadow_rs::shadow;
use crate::AppState;
shadow!(build);
pub async fn get_index(
State(app_state): State<AppState>,
) -> impl IntoResponse {
@ -12,3 +15,11 @@ pub async fn get_index(
"base_uri": app_state.config.instance.base_uri
}))
}
pub async fn get_app_version() -> impl IntoResponse {
Json(json!({
"tag": shadow_rs::tag(),
"branch": shadow_rs::branch(),
"commit_hash": build::COMMIT_HASH
}))
}

View file

@ -1,7 +1,7 @@
use axum::{extract::State, http::StatusCode, response::{Html, IntoResponse}, Extension, Form, Json};
use chrono::{Duration, Utc};
use fully_pub::fully_pub;
use log::error;
use log::{debug, error};
use serde::{Deserialize, Serialize};
use kernel::{models::authorization::Authorization, repositories::users::get_user_by_id};
@ -11,7 +11,7 @@ use crate::{
const AUTHORIZATION_CODE_TTL_SECONDS: i64 = 120;
#[derive(Serialize, Deserialize)]
#[derive(Serialize, Deserialize, Debug)]
#[fully_pub]
struct AccessTokenRequestParams {
grant_type: String,
@ -48,6 +48,7 @@ pub async fn get_access_token(
let authorization = match authorizations_res {
Ok(val) => val,
Err(sqlx::Error::RowNotFound) => {
error!("Received invalid authorization_code.");
return (
StatusCode::BAD_REQUEST,
Json("Invalid authorization_code.")
@ -68,12 +69,15 @@ pub async fn get_access_token(
Utc::now().signed_duration_since(ts) < Duration::seconds(AUTHORIZATION_CODE_TTL_SECONDS)
});
if !is_code_valid {
debug!("Received expired authorization code");
return (
StatusCode::BAD_REQUEST,
Json("Authorization code has expired.")
).into_response();
}
debug!("Generating access_token and id_token.");
// 2.3. Fetch user resource owner
let user = get_user_by_id(&app_state.db, &authorization.user_id)
.await

View file

@ -1,5 +1,3 @@
use std::str::FromStr;
use jsonwebkey_convert_repaired::RSAPublicKey;
use jsonwebkey_convert_repaired::der::FromPem;

View file

@ -17,6 +17,7 @@ struct WellKnownOpenIdConfiguration {
userinfo_endpoint: String,
scopes_supported: Vec<String>,
response_types_supported: Vec<String>,
subject_types_supported: Vec<String>,
token_endpoint_auth_methods_supported: Vec<String>,
id_token_signing_alg_values_supported: Vec<String>,
jwks_uri: String
@ -33,6 +34,7 @@ pub async fn get_well_known_openid_configuration(
userinfo_endpoint: format!("{}/api/user", base_url),
scopes_supported: AuthorizationScope::iter().map(|v| v.to_string()).collect(),
response_types_supported: vec!["code".into()],
subject_types_supported: vec!["public".into(), "pairwise".into()],
token_endpoint_auth_methods_supported: vec!["client_secret_basic".into()],
id_token_signing_alg_values_supported: vec!["RS256".into()],
jwks_uri: format!("{}/.well-known/jwks", base_url)

View file

@ -11,7 +11,7 @@ pub async fn get_user_asset(
Err(_) => {
return (
StatusCode::NOT_FOUND,
Html("Could not find user asset")
Html("Could not find user asset.")
).into_response();
},
Ok(ua) => ua
@ -22,6 +22,6 @@ pub async fn get_user_asset(
header::CONTENT_TYPE,
HeaderValue::from_str(&user_asset.mime_type).expect("Constructing header value.")
);
(hm, user_asset.content).into_response()
}

View file

@ -46,11 +46,12 @@ pub fn build_router(server_config: &ServerConfig, app_state: AppState) -> Router
let api_user_routes = Router::new()
.route("/api/user", get(api::read_user::read_user_basic))
.layer(middleware::from_fn_with_state(app_state.clone(), app_auth::enforce_jwt_auth_middleware))
.route("/api", get(api::index::get_index))
.route("/api/user-assets/:asset_id", get(api::public_assets::get_user_asset));
.layer(middleware::from_fn_with_state(app_state.clone(), app_auth::enforce_jwt_auth_middleware));
let well_known_routes = Router::new()
let public_api_routes = Router::new()
.route("/api", get(api::index::get_index))
.route("/api/user-assets/{asset_id}", get(api::public_assets::get_user_asset))
.route("/.app-version", get(api::index::get_app_version))
.route("/.well-known/openid-configuration", get(api::openid::well_known::get_well_known_openid_configuration))
.route("/.well-known/jwks", get(api::openid::keys::get_signing_public_keys));
@ -59,7 +60,7 @@ pub fn build_router(server_config: &ServerConfig, app_state: AppState) -> Router
.merge(user_routes)
.merge(api_app_routes)
.merge(api_user_routes)
.merge(well_known_routes)
.merge(public_api_routes)
.nest_service(
"/assets",
ServeDir::new(server_config.assets_path.clone())

View file

@ -1,3 +1,6 @@
use std::ascii::AsciiExt;
use axum::body::HttpBody;
use fully_pub::fully_pub;
use jsonwebtoken::get_current_timestamp;
use kernel::models::{authorization::AuthorizationScope, config::Config, user::User};
@ -64,7 +67,9 @@ impl OAuth2AccessTokenClaims {
#[derive(Debug, Serialize, Deserialize, Clone)]
#[fully_pub]
struct OIDCIdTokenClaims {
/// Token expiration
/// Issued at date
iat: u64,
/// Token expiration date
exp: u64,
/// Token issuer (URI to the issuer)
iss: String,
@ -72,10 +77,15 @@ struct OIDCIdTokenClaims {
aud: String,
/// End-user id assigned by the issuer (user_id)
sub: String,
/// additional claims
name: Option<String>,
/// Displayable name
name: String,
email: Option<String>,
/// handle of user
preferred_username: Option<String>,
/// Public URL to the user asset id (for now)
/// In the future, we should create a unique link per authorization
#[serde(skip_serializing_if = "Option::is_none")]
picture: Option<String>,
roles: Vec<String>,
nonce: Option<String>
}
@ -91,9 +101,11 @@ impl OIDCIdTokenClaims {
iss: config.instance.base_uri.clone(),
aud: client_id.into(),
sub: user.id,
iat: get_current_timestamp(),
exp: get_current_timestamp() + 86_000,
email: user.email,
name: user.full_name,
name: user.full_name.unwrap_or(user.handle.clone()),
picture: user.avatar_asset_id.map(|asset_id| format!("{}/api/user-assets/{}", &config.instance.base_uri, asset_id)),
preferred_username: Some(user.handle),
roles: user.roles.0,
nonce