Compare commits
8 commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 941f4846c7 | |||
| 77104472df | |||
| 283c4ebad2 | |||
| fd0fc47ccf | |||
| 61d4cc3978 | |||
| 073a5ac512 | |||
| 905c57000a | |||
| 18b33c00a7 |
12 changed files with 1195 additions and 464 deletions
1556
Cargo.lock
generated
1556
Cargo.lock
generated
File diff suppressed because it is too large
Load diff
|
|
@ -50,3 +50,4 @@ redis = { version = "0.27.3", default-features = false, features = ["acl"] }
|
||||||
# Auth utils
|
# Auth utils
|
||||||
totp-rs = "5.6"
|
totp-rs = "5.6"
|
||||||
|
|
||||||
|
shadow-rs = { version = "1.2.0", features = ["metadata", "std"] }
|
||||||
|
|
|
||||||
8
TODO.md
8
TODO.md
|
|
@ -62,3 +62,11 @@
|
||||||
- [ ] implement docker secrets. https://docs.docker.com/engine/swarm/secrets/
|
- [ ] implement docker secrets. https://docs.docker.com/engine/swarm/secrets/
|
||||||
|
|
||||||
- [ ] Find a minimal OpenID client implementation like Listmonk but a little bit more mature
|
- [ ] 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.
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ kernel = { path = "../kernel" }
|
||||||
utils = { path = "../utils" }
|
utils = { path = "../utils" }
|
||||||
|
|
||||||
# common
|
# common
|
||||||
|
shadow-rs = { workspace = true }
|
||||||
log = { workspace = true }
|
log = { workspace = true }
|
||||||
env_logger = { workspace = true }
|
env_logger = { workspace = true }
|
||||||
|
|
||||||
|
|
@ -19,23 +20,8 @@ fully_pub = { workspace = true }
|
||||||
|
|
||||||
tokio = { 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 = { workspace = true }
|
||||||
serde_json = { workspace = true }
|
serde_json = { workspace = true }
|
||||||
serde_urlencoded = "0.7.1"
|
|
||||||
chrono = { workspace = true }
|
chrono = { workspace = true }
|
||||||
|
|
||||||
argh = { workspace = true }
|
argh = { workspace = true }
|
||||||
|
|
@ -44,6 +30,22 @@ sqlx = { workspace = true }
|
||||||
uuid = { workspace = true }
|
uuid = { workspace = true }
|
||||||
url = { 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
|
# To work with key pair
|
||||||
pem = "3.0.4"
|
pem = "3.0.4"
|
||||||
|
|
||||||
|
|
@ -53,6 +55,7 @@ branch = "master"
|
||||||
features = ["simple_asn1", "pem"]
|
features = ["simple_asn1", "pem"]
|
||||||
|
|
||||||
[build-dependencies]
|
[build-dependencies]
|
||||||
|
shadow-rs = { workspace = true }
|
||||||
minijinja-embed = "2.3.1"
|
minijinja-embed = "2.3.1"
|
||||||
|
|
||||||
[[bin]]
|
[[bin]]
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,6 @@
|
||||||
|
use shadow_rs::ShadowBuilder;
|
||||||
|
|
||||||
fn main() {
|
fn main() {
|
||||||
|
ShadowBuilder::builder().build().unwrap();
|
||||||
minijinja_embed::embed_templates!("src/templates");
|
minijinja_embed::embed_templates!("src/templates");
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,11 @@
|
||||||
use axum::{extract::State, response::IntoResponse, Json};
|
use axum::{extract::State, response::IntoResponse, Json};
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
|
use shadow_rs::shadow;
|
||||||
|
|
||||||
use crate::AppState;
|
use crate::AppState;
|
||||||
|
|
||||||
|
shadow!(build);
|
||||||
|
|
||||||
pub async fn get_index(
|
pub async fn get_index(
|
||||||
State(app_state): State<AppState>,
|
State(app_state): State<AppState>,
|
||||||
) -> impl IntoResponse {
|
) -> impl IntoResponse {
|
||||||
|
|
@ -12,3 +15,11 @@ pub async fn get_index(
|
||||||
"base_uri": app_state.config.instance.base_uri
|
"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
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
use axum::{extract::State, http::StatusCode, response::{Html, IntoResponse}, Extension, Form, Json};
|
use axum::{extract::State, http::StatusCode, response::{Html, IntoResponse}, Extension, Form, Json};
|
||||||
use chrono::{Duration, Utc};
|
use chrono::{Duration, Utc};
|
||||||
use fully_pub::fully_pub;
|
use fully_pub::fully_pub;
|
||||||
use log::error;
|
use log::{debug, error};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
use kernel::{models::authorization::Authorization, repositories::users::get_user_by_id};
|
use kernel::{models::authorization::Authorization, repositories::users::get_user_by_id};
|
||||||
|
|
@ -11,7 +11,7 @@ use crate::{
|
||||||
|
|
||||||
const AUTHORIZATION_CODE_TTL_SECONDS: i64 = 120;
|
const AUTHORIZATION_CODE_TTL_SECONDS: i64 = 120;
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize)]
|
#[derive(Serialize, Deserialize, Debug)]
|
||||||
#[fully_pub]
|
#[fully_pub]
|
||||||
struct AccessTokenRequestParams {
|
struct AccessTokenRequestParams {
|
||||||
grant_type: String,
|
grant_type: String,
|
||||||
|
|
@ -48,6 +48,7 @@ pub async fn get_access_token(
|
||||||
let authorization = match authorizations_res {
|
let authorization = match authorizations_res {
|
||||||
Ok(val) => val,
|
Ok(val) => val,
|
||||||
Err(sqlx::Error::RowNotFound) => {
|
Err(sqlx::Error::RowNotFound) => {
|
||||||
|
error!("Received invalid authorization_code.");
|
||||||
return (
|
return (
|
||||||
StatusCode::BAD_REQUEST,
|
StatusCode::BAD_REQUEST,
|
||||||
Json("Invalid authorization_code.")
|
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)
|
Utc::now().signed_duration_since(ts) < Duration::seconds(AUTHORIZATION_CODE_TTL_SECONDS)
|
||||||
});
|
});
|
||||||
if !is_code_valid {
|
if !is_code_valid {
|
||||||
|
debug!("Received expired authorization code");
|
||||||
return (
|
return (
|
||||||
StatusCode::BAD_REQUEST,
|
StatusCode::BAD_REQUEST,
|
||||||
Json("Authorization code has expired.")
|
Json("Authorization code has expired.")
|
||||||
).into_response();
|
).into_response();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
debug!("Generating access_token and id_token.");
|
||||||
|
|
||||||
// 2.3. Fetch user resource owner
|
// 2.3. Fetch user resource owner
|
||||||
let user = get_user_by_id(&app_state.db, &authorization.user_id)
|
let user = get_user_by_id(&app_state.db, &authorization.user_id)
|
||||||
.await
|
.await
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,3 @@
|
||||||
use std::str::FromStr;
|
|
||||||
|
|
||||||
use jsonwebkey_convert_repaired::RSAPublicKey;
|
use jsonwebkey_convert_repaired::RSAPublicKey;
|
||||||
use jsonwebkey_convert_repaired::der::FromPem;
|
use jsonwebkey_convert_repaired::der::FromPem;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ struct WellKnownOpenIdConfiguration {
|
||||||
userinfo_endpoint: String,
|
userinfo_endpoint: String,
|
||||||
scopes_supported: Vec<String>,
|
scopes_supported: Vec<String>,
|
||||||
response_types_supported: Vec<String>,
|
response_types_supported: Vec<String>,
|
||||||
|
subject_types_supported: Vec<String>,
|
||||||
token_endpoint_auth_methods_supported: Vec<String>,
|
token_endpoint_auth_methods_supported: Vec<String>,
|
||||||
id_token_signing_alg_values_supported: Vec<String>,
|
id_token_signing_alg_values_supported: Vec<String>,
|
||||||
jwks_uri: String
|
jwks_uri: String
|
||||||
|
|
@ -33,6 +34,7 @@ pub async fn get_well_known_openid_configuration(
|
||||||
userinfo_endpoint: format!("{}/api/user", base_url),
|
userinfo_endpoint: format!("{}/api/user", base_url),
|
||||||
scopes_supported: AuthorizationScope::iter().map(|v| v.to_string()).collect(),
|
scopes_supported: AuthorizationScope::iter().map(|v| v.to_string()).collect(),
|
||||||
response_types_supported: vec!["code".into()],
|
response_types_supported: vec!["code".into()],
|
||||||
|
subject_types_supported: vec!["public".into(), "pairwise".into()],
|
||||||
token_endpoint_auth_methods_supported: vec!["client_secret_basic".into()],
|
token_endpoint_auth_methods_supported: vec!["client_secret_basic".into()],
|
||||||
id_token_signing_alg_values_supported: vec!["RS256".into()],
|
id_token_signing_alg_values_supported: vec!["RS256".into()],
|
||||||
jwks_uri: format!("{}/.well-known/jwks", base_url)
|
jwks_uri: format!("{}/.well-known/jwks", base_url)
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@ pub async fn get_user_asset(
|
||||||
Err(_) => {
|
Err(_) => {
|
||||||
return (
|
return (
|
||||||
StatusCode::NOT_FOUND,
|
StatusCode::NOT_FOUND,
|
||||||
Html("Could not find user asset")
|
Html("Could not find user asset.")
|
||||||
).into_response();
|
).into_response();
|
||||||
},
|
},
|
||||||
Ok(ua) => ua
|
Ok(ua) => ua
|
||||||
|
|
|
||||||
|
|
@ -46,11 +46,12 @@ pub fn build_router(server_config: &ServerConfig, app_state: AppState) -> Router
|
||||||
|
|
||||||
let api_user_routes = Router::new()
|
let api_user_routes = Router::new()
|
||||||
.route("/api/user", get(api::read_user::read_user_basic))
|
.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))
|
.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));
|
|
||||||
|
|
||||||
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/openid-configuration", get(api::openid::well_known::get_well_known_openid_configuration))
|
||||||
.route("/.well-known/jwks", get(api::openid::keys::get_signing_public_keys));
|
.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(user_routes)
|
||||||
.merge(api_app_routes)
|
.merge(api_app_routes)
|
||||||
.merge(api_user_routes)
|
.merge(api_user_routes)
|
||||||
.merge(well_known_routes)
|
.merge(public_api_routes)
|
||||||
.nest_service(
|
.nest_service(
|
||||||
"/assets",
|
"/assets",
|
||||||
ServeDir::new(server_config.assets_path.clone())
|
ServeDir::new(server_config.assets_path.clone())
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,6 @@
|
||||||
|
use std::ascii::AsciiExt;
|
||||||
|
|
||||||
|
use axum::body::HttpBody;
|
||||||
use fully_pub::fully_pub;
|
use fully_pub::fully_pub;
|
||||||
use jsonwebtoken::get_current_timestamp;
|
use jsonwebtoken::get_current_timestamp;
|
||||||
use kernel::models::{authorization::AuthorizationScope, config::Config, user::User};
|
use kernel::models::{authorization::AuthorizationScope, config::Config, user::User};
|
||||||
|
|
@ -64,7 +67,9 @@ impl OAuth2AccessTokenClaims {
|
||||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||||
#[fully_pub]
|
#[fully_pub]
|
||||||
struct OIDCIdTokenClaims {
|
struct OIDCIdTokenClaims {
|
||||||
/// Token expiration
|
/// Issued at date
|
||||||
|
iat: u64,
|
||||||
|
/// Token expiration date
|
||||||
exp: u64,
|
exp: u64,
|
||||||
/// Token issuer (URI to the issuer)
|
/// Token issuer (URI to the issuer)
|
||||||
iss: String,
|
iss: String,
|
||||||
|
|
@ -72,10 +77,15 @@ struct OIDCIdTokenClaims {
|
||||||
aud: String,
|
aud: String,
|
||||||
/// End-user id assigned by the issuer (user_id)
|
/// End-user id assigned by the issuer (user_id)
|
||||||
sub: String,
|
sub: String,
|
||||||
/// additional claims
|
/// Displayable name
|
||||||
name: Option<String>,
|
name: String,
|
||||||
email: Option<String>,
|
email: Option<String>,
|
||||||
|
/// handle of user
|
||||||
preferred_username: Option<String>,
|
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>,
|
roles: Vec<String>,
|
||||||
nonce: Option<String>
|
nonce: Option<String>
|
||||||
}
|
}
|
||||||
|
|
@ -91,9 +101,11 @@ impl OIDCIdTokenClaims {
|
||||||
iss: config.instance.base_uri.clone(),
|
iss: config.instance.base_uri.clone(),
|
||||||
aud: client_id.into(),
|
aud: client_id.into(),
|
||||||
sub: user.id,
|
sub: user.id,
|
||||||
|
iat: get_current_timestamp(),
|
||||||
exp: get_current_timestamp() + 86_000,
|
exp: get_current_timestamp() + 86_000,
|
||||||
email: user.email,
|
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),
|
preferred_username: Some(user.handle),
|
||||||
roles: user.roles.0,
|
roles: user.roles.0,
|
||||||
nonce
|
nonce
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue