Compare commits

...

2 commits

Author SHA1 Message Date
02e16a7e74 feat: support OIDC id_token
- generate JWT id_token in token exchange
- store optional nonce in authorization object
- switch to RS256 algorithm for JWT signature
- add JWKs endpoint to provide OIDC clients with public keys
2024-12-17 22:32:25 +01:00
4763915812 feat: user avatar as public asset 2024-12-17 22:32:25 +01:00
43 changed files with 641 additions and 124 deletions

1
.env
View file

@ -1 +0,0 @@
APP_JWT_SECRET=bc1996ea-5464-424a-9a38-5604f2bc865a

BIN
.swp Normal file

Binary file not shown.

57
Cargo.lock generated
View file

@ -349,6 +349,12 @@ dependencies = [
"windows-targets 0.52.6",
]
[[package]]
name = "base64"
version = "0.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9e1b586273c5702936fe7b7d6896644d8be71e6314cfe09d3167c95f712589e8"
[[package]]
name = "base64"
version = "0.21.7"
@ -879,6 +885,12 @@ version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
[[package]]
name = "hex-literal"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6fe2267d4ed49bc07b63801559be28c718ea06c4738b7a03c94df7386d2cde46"
[[package]]
name = "hkdf"
version = "0.12.4"
@ -960,11 +972,13 @@ dependencies = [
"chrono",
"env_logger",
"fully_pub",
"jsonwebkey-convert",
"jsonwebtoken",
"kernel",
"log",
"minijinja",
"minijinja-embed",
"pem 3.0.4",
"serde",
"serde_json",
"serde_urlencoded",
@ -1232,6 +1246,20 @@ dependencies = [
"wasm-bindgen",
]
[[package]]
name = "jsonwebkey-convert"
version = "0.3.0"
dependencies = [
"base64 0.13.1",
"lazy_static",
"num-bigint",
"pem 0.8.3",
"serde",
"serde_json",
"simple_asn1 0.5.4",
"thiserror 1.0.69",
]
[[package]]
name = "jsonwebtoken"
version = "9.3.0"
@ -1240,11 +1268,11 @@ checksum = "b9ae10193d25051e74945f1ea2d0b42e03cc3b890f7e4cc5faa44997d808193f"
dependencies = [
"base64 0.21.7",
"js-sys",
"pem",
"pem 3.0.4",
"ring",
"serde",
"serde_json",
"simple_asn1",
"simple_asn1 0.6.2",
]
[[package]]
@ -1256,9 +1284,11 @@ dependencies = [
"dotenvy",
"env_logger",
"fully_pub",
"hex-literal",
"log",
"serde",
"serde_json",
"sha2",
"sqlx",
"strum",
"strum_macros",
@ -1553,6 +1583,17 @@ version = "1.0.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a"
[[package]]
name = "pem"
version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fd56cbd21fea48d0c440b41cd69c589faacade08c992d9a54e471b79d0fd13eb"
dependencies = [
"base64 0.13.1",
"once_cell",
"regex",
]
[[package]]
name = "pem"
version = "3.0.4"
@ -1913,6 +1954,18 @@ dependencies = [
"rand_core",
]
[[package]]
name = "simple_asn1"
version = "0.5.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8eb4ea60fb301dc81dfc113df680571045d375ab7345d171c5dc7d7e13107a80"
dependencies = [
"chrono",
"num-bigint",
"num-traits",
"thiserror 1.0.69",
]
[[package]]
name = "simple_asn1"
version = "0.6.2"

View file

@ -23,6 +23,8 @@ strum_macros = "0.26"
uuid = { version = "1.8", features = ["serde", "v4"] }
dotenvy = "0.15.7"
url = "2.5.3"
sha2 = "0.10"
hex-literal = "0.4"
# CLI
argh = "0.1"

View file

@ -1,5 +1,8 @@
# TODO
- [ ] better OIDC support
- [ ] better support of `profile` `openid` `email` `roles` scopes
- [ ] i18n strings in the http website.
- [ ] Instance customization support
@ -47,3 +50,8 @@
- [x] UserWebGUI: activate account with token
- [X] basic docker setup
- [ ] make `docker stop` working (handle SIGTERM/SIGINT)
- [ ] implement docker secrets. https://docs.docker.com/engine/swarm/secrets/
- [ ] Find a minimal OpenID client implementation like Listmonk but a little bit more mature

View file

@ -1,8 +1,23 @@
signing_key = "tmp/secrets/signing.key"
[instance]
base_uri = "http://localhost:8085"
name = "Example org"
base_uri = "https://auth.fictive.org"
name = "Fictive's auth"
logo_uri = "https://example.org/logo.png"
[[applications]]
slug = "listmonk"
name = "Listmonk"
description = "Newsletter tool."
client_id = "da2120b4-635d-4eb5-8b2f-dbae89f6a6e9"
client_secret = "59da2291-8999-40e2-afe9-a54ac7cd0a94"
login_uri = "https://lists.fictive.org"
allowed_redirect_uris = [
"https://lists.fictive.org/auth/oidc",
]
visibility = "Internal"
authorize_flow = "Implicit"
[[applications]]
slug = "demo_app"
name = "Demo app"

View file

@ -1,18 +1,18 @@
export RUST_BACKTRACE := "1"
export RUST_LOG := "trace"
export CONTEXT_ARGS := "--config ./config.toml --database ./tmp/dbs/minauthator.db --static-assets ./assets"
export CONTEXT_ARGS := "--config config.toml --database tmp/dbs/minauthator.db --static-assets ./assets"
watch-server:
cargo-watch -x "run --bin minauthator-server -- $CONTEXT_ARGS"
watch-server *args:
cargo-watch -x "run --bin minauthator-server -- $CONTEXT_ARGS {{args}}"
server:
cargo run --bin minauthator-server -- $CONTEXT_ARGS
server *args:
cargo run --bin minauthator-server -- $CONTEXT_ARGS {{args}}
admin:
cargo run --bin minauthator-admin -- $CONTEXT_ARGS
admin *args:
cargo run --bin minauthator-admin -- $CONTEXT_ARGS {{args}}
docker-build:
docker build -t lefuturiste/minauthator .
docker-build *args:
docker build -t lefuturiste/minauthator {{args}} .
docker-init-db:
docker run \
@ -28,6 +28,6 @@ docker-run:
-v minauthator-db:/var/lib/minauthator \
lefuturiste/minauthator
init-db:
sqlite3 -echo tmp/dbs/minauthator.db < migrations/all.sql
init-db *args:
sqlite3 {{args}} tmp/dbs/minauthator.db < migrations/all.sql

View file

@ -43,6 +43,15 @@ argh = { workspace = true }
sqlx = { workspace = true }
uuid = { workspace = true }
url = { workspace = true }
pem = "3.0.4"
# For now, we test if it's viable, and later we will fork it to fix the build (cf. issue
# https://github.com/informationsea/jsonwebkey-rs#1 )
[dependencies.jsonwebkey-convert]
path = "/home/mbess/workspace/foss/rust_libs/jsonwebkey-rs/jsonwebkey-convert"
features = ["simple_asn1", "pem"]
pem = "3.0.4"
[build-dependencies]
minijinja-embed = "2.3.1"

View file

@ -2,3 +2,4 @@ pub mod index;
pub mod oauth2;
pub mod read_user;
pub mod openid;
pub mod public_assets;

View file

@ -4,9 +4,9 @@ use fully_pub::fully_pub;
use log::error;
use serde::{Deserialize, Serialize};
use kernel::models::authorization::Authorization;
use kernel::{models::authorization::Authorization, repositories::users::get_user_by_id};
use crate::{
services::{app_session::AppClientSession, session::create_token}, token_claims::AppUserTokenClaims, AppState
services::{app_session::AppClientSession, session::create_token}, token_claims::{OAuth2AccessTokenClaims, OIDCIdTokenClaims}, AppState
};
const AUTHORIZATION_CODE_TTL_SECONDS: i64 = 120;
@ -22,6 +22,7 @@ struct AccessTokenRequestParams {
#[derive(Serialize, Deserialize)]
#[fully_pub]
struct AccessTokenResponse {
id_token: String,
access_token: String,
token_type: String,
expires_in: u64
@ -60,6 +61,7 @@ pub async fn get_access_token(
).into_response();
}
};
// 2.2. Validate that the authorization code is not expired
let is_code_valid = authorization.last_used_at
.map_or(false, |ts| {
@ -72,19 +74,38 @@ pub async fn get_access_token(
).into_response();
}
// 3. Generate JWT for oauth2 client user session
let jwt = create_token(
// 2.3. Fetch user resource owner
let user = get_user_by_id(&app_state.db, &authorization.user_id)
.await
.expect("Expected to get user from authorization.");
// 3.1. Generate JWT for OAuth2 client user session
let access_token_jwt = create_token(
&app_state.config,
&app_state.secrets,
AppUserTokenClaims::new(
&app_client_session.client_id,
&authorization.user_id,
OAuth2AccessTokenClaims::new(
&app_state.config,
&user,
authorization.scopes.to_vec()
)
);
// 3.2. Generate id_token for OIDC client
let id_token_claims = OIDCIdTokenClaims::new(
&app_state.config,
&app_client_session.client_id,
user.clone(),
authorization.nonce.clone()
);
let id_token_jwt = create_token(
&app_state.config,
&app_state.secrets,
id_token_claims
);
// 4. return JWT
let access_token_res = AccessTokenResponse {
access_token: jwt,
token_type: "jwt".to_string(),
id_token: id_token_jwt,
access_token: access_token_jwt,
token_type: "Bearer".to_string(),
expires_in: 3600
};
Json(access_token_res).into_response()

View file

@ -0,0 +1,45 @@
use jsonwebkey_convert::RSAPublicKey;
use jsonwebkey_convert::der::FromPem;
use axum::{extract::State, response::IntoResponse, Json};
use fully_pub::fully_pub;
use serde::Serialize;
use crate::AppState;
// /// JSON Web Key
// /// @See https://www.rfc-editor.org/rfc/rfc7517.html
// #[derive(Serialize)]
// #[fully_pub]
// struct RsaJWK {
// #[serde(rename = "use")]
// utilisation: String,
// alg: String,
// kid: String,
// #[serde(rename = "modulus")]
// modulus: String,
// exp: String
// }
/// JSON Web Key set
/// @See https://www.rfc-editor.org/rfc/rfc7517.html
#[derive(Serialize)]
#[fully_pub]
struct JWKs {
keys: Vec<RSAPublicKey>
}
pub async fn get_signing_public_keys(
State(app_state): State<AppState>,
) -> impl IntoResponse {
let pem_data = app_state.secrets.signing_keypair.0;
// extract modulus and exp number from ASN.1 encoded PCKS 1 package
let rsa_jwk = RSAPublicKey::from_pem(pem_data)
.expect("Expected to decode PEM public key");
dbg!(&rsa_jwk);
Json(JWKs {
keys: vec![rsa_jwk]
}).into_response()
}

View file

@ -1 +1,2 @@
pub mod well_known;
pub mod keys;

View file

@ -6,6 +6,8 @@ use strum::IntoEnumIterator;
use crate::AppState;
/// Manifest used by OpenID Connect clients
/// @See https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderMetadata
#[derive(Serialize)]
#[fully_pub]
struct WellKnownOpenIdConfiguration {
@ -15,7 +17,9 @@ struct WellKnownOpenIdConfiguration {
userinfo_endpoint: String,
scopes_supported: Vec<String>,
response_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>,
jwks_uri: String
}
pub async fn get_well_known_openid_configuration(
@ -30,5 +34,9 @@ pub async fn get_well_known_openid_configuration(
scopes_supported: AuthorizationScope::iter().map(|v| v.to_string()).collect(),
response_types_supported: vec!["code".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)
// jwks_uri:
// subject_types_supported
})
}

View file

@ -0,0 +1,27 @@
use axum::{extract::{Path, State}, http::{header, HeaderMap, HeaderValue, StatusCode}, response::{Html, IntoResponse}};
use kernel::repositories::users::get_user_asset_by_id;
use crate::AppState;
pub async fn get_user_asset(
State(app_state): State<AppState>,
Path(asset_id): Path<String>
) -> impl IntoResponse {
let user_asset = match get_user_asset_by_id(&app_state.db, &asset_id).await {
Err(_) => {
return (
StatusCode::NOT_FOUND,
Html("Could not find user asset")
).into_response();
},
Ok(ua) => ua
};
let mut hm = HeaderMap::new();
hm.insert(
header::CONTENT_TYPE,
HeaderValue::from_str(&user_asset.mime_type).expect("Constructing header value.")
);
(hm, user_asset.content).into_response()
}

View file

@ -2,7 +2,7 @@ use axum::{extract::State, response::IntoResponse, Extension, Json};
use fully_pub::fully_pub;
use serde::Serialize;
use crate::{token_claims::AppUserTokenClaims, AppState};
use crate::{token_claims::OAuth2AccessTokenClaims, AppState};
use kernel::models::user::User;
#[derive(Serialize)]
@ -18,11 +18,11 @@ struct ReadUserBasicExtract {
pub async fn read_user_basic(
State(app_state): State<AppState>,
Extension(token_claims): Extension<AppUserTokenClaims>,
Extension(token_claims): Extension<OAuth2AccessTokenClaims>,
) -> impl IntoResponse {
// 1. This handler require app user authentification (JWT)
let user_res = sqlx::query_as::<_, User>("SELECT * FROM users WHERE id = $1")
.bind(&token_claims.user_id)
.bind(&token_claims.sub)
.fetch_one(&app_state.db.0)
.await
.expect("To get user from claim");

View file

@ -7,9 +7,7 @@ use serde::{Deserialize, Serialize};
use url::Url;
use uuid::Uuid;
use kernel::{
models::{authorization::Authorization, config::AppAuthorizeFlow}
};
use kernel::models::{authorization::Authorization, config::AppAuthorizeFlow};
use utils::get_random_alphanumerical;
use crate::{
renderer::TemplateRenderer, services::oauth2::{parse_scope, verify_redirect_uri}, token_claims::UserTokenClaims, AppState
@ -25,6 +23,7 @@ struct AuthorizationParams {
redirect_uri: String,
/// An opaque value used by the client to maintain state between the request and callback
state: String,
nonce: Option<String>
}
fn redirect_to_client(
@ -34,7 +33,7 @@ fn redirect_to_client(
let target_url = format!("{}?code={}&state={}",
authorization_params.redirect_uri,
authorization_code,
authorization_params.state,
authorization_params.state
);
debug!("Redirecting to {}", target_url);
@ -56,6 +55,7 @@ pub async fn authorize_form(
query_params: Query<AuthorizationParams>
) -> impl IntoResponse {
let Query(authorization_params) = query_params;
dbg!(&authorization_params);
// 1. Verify the app details
let app = match app_state.config.applications
@ -116,9 +116,10 @@ pub async fn authorize_form(
// Create new auth code
let authorization_code = get_random_alphanumerical(32);
// Update last used timestamp for this authorization
let _result = sqlx::query("UPDATE authorizations SET code = $2, last_used_at = $3 WHERE id = $1")
let _result = sqlx::query("UPDATE authorizations SET code = $2, nonce = $3, last_used_at = $4 WHERE id = $1")
.bind(existing_authorization.id)
.bind(authorization_code.clone())
.bind(authorization_params.nonce.clone())
.bind(Utc::now().to_rfc3339_opts(SecondsFormat::Millis, true))
.execute(&app_state.db.0)
.await.unwrap();
@ -172,6 +173,7 @@ pub async fn perform_authorize(
Extension(token_claims): Extension<UserTokenClaims>,
Form(authorize_form): Form<AuthorizationParams>
) -> impl IntoResponse {
dbg!(&authorize_form);
// 1. Get the app details
let app = match app_state.config.applications
.iter()
@ -203,6 +205,7 @@ pub async fn perform_authorize(
client_id: app.client_id.clone(),
scopes: sqlx::types::Json(scopes),
code: authorization_code.clone(),
nonce: authorize_form.nonce.clone(),
last_used_at: Some(Utc::now()),
created_at: Utc::now(),
};
@ -210,14 +213,15 @@ pub async fn perform_authorize(
// 3. Save authorization in DB with state
let res = sqlx::query("
INSERT INTO authorizations
(id, user_id, client_id, scopes, code, last_used_at, created_at)
VALUES ($1, $2, $3, $4, $5, $6, $7)
(id, user_id, client_id, scopes, code, nonce, last_used_at, created_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
")
.bind(authorization.id.clone())
.bind(authorization.user_id)
.bind(authorization.client_id)
.bind(authorization.scopes)
.bind(authorization.code)
.bind(authorization.nonce)
.bind(authorization.last_used_at.map(|x| x.to_rfc3339_opts(SecondsFormat::Millis, true)))
.bind(authorization.created_at.to_rfc3339_opts(SecondsFormat::Millis, true))
.execute(&app_state.db.0)

View file

@ -91,8 +91,8 @@ pub async fn perform_login(
.await.unwrap();
let jwt_max_age = Duration::days(15);
let claims = UserTokenClaims::new(&user.id, jwt_max_age);
let jwt = create_token(&app_state.secrets, claims);
let claims = UserTokenClaims::new(&app_state.config, &user.id, jwt_max_age);
let jwt = create_token(&app_state.config, &app_state.secrets, claims);
// TODO: handle keep_session boolean from form and specify cookie max age only if this setting
// is true

View file

@ -1,7 +1,8 @@
use anyhow::Context;
use axum::{body::Bytes, extract::State, response::IntoResponse, Extension};
use axum_typed_multipart::{FieldData, TryFromMultipart, TypedMultipart};
use fully_pub::fully_pub;
use log::error;
use log::{error, info};
use minijinja::context;
use crate::{
@ -9,7 +10,7 @@ use crate::{
renderer::TemplateRenderer,
AppState
};
use kernel::models::user::User;
use kernel::{models::{user::User, user_asset::UserAsset}, repositories::users::create_user_asset};
pub async fn me_page(
State(app_state): State<AppState>,
@ -61,7 +62,7 @@ struct UserDetailsUpdateForm {
website: String,
#[form_data(limit = "5MiB")]
picture: FieldData<Bytes>
avatar: FieldData<Bytes>
}
@ -73,17 +74,41 @@ pub async fn me_perform_update_details(
) -> impl IntoResponse {
let template_path = "pages/me/details-form";
let update_res = sqlx::query("UPDATE users SET handle = $2, email = $3, full_name = $4, website = $5, picture = $6 WHERE id = $1")
let user = sqlx::query_as::<_, User>("SELECT * FROM users WHERE id = $1")
.bind(&token_claims.sub)
.fetch_one(&app_state.db.0)
.await
.expect("To get user from claim");
let update_res = sqlx::query("UPDATE users SET handle = $2, email = $3, full_name = $4, website = $5 WHERE id = $1")
.bind(&token_claims.sub)
.bind(details_update.handle)
.bind(details_update.email)
.bind(details_update.full_name)
.bind(details_update.website)
.bind(details_update.picture.contents.to_vec())
.execute(&app_state.db.0)
.await;
let user_res = sqlx::query_as::<_, User>("SELECT * FROM users WHERE id = $1")
if !details_update.avatar.contents.is_empty() {
let user_asset = UserAsset::new(
&user,
details_update.avatar.contents.to_vec(),
details_update.avatar.metadata.content_type.expect("Expected mimetype on avatar content"),
details_update.avatar.metadata.name
);
let _update_res = sqlx::query("UPDATE users SET avatar_asset_id = $2 WHERE id = $1")
.bind(&token_claims.sub)
.bind(user_asset.id.clone())
.execute(&app_state.db.0)
.await;
// TODO: handle possible error
let _ = create_user_asset(&app_state.db, user_asset)
.await
.context("Creating user avatar asset.");
info!("Uploaded new avatar as user asset");
}
let user = sqlx::query_as::<_, User>("SELECT * FROM users WHERE id = $1")
.bind(&token_claims.sub)
.fetch_one(&app_state.db.0)
.await
@ -95,7 +120,7 @@ pub async fn me_perform_update_details(
template_path,
context!(
success => true,
user => user_res
user => user
)
)
},
@ -105,7 +130,7 @@ pub async fn me_perform_update_details(
template_path,
context!(
error => Some("Cannot update user details.".to_string()),
user => user_res
user => user
)
)
}

View file

@ -46,7 +46,7 @@ pub async fn perform_register(
email: Some(register.email),
handle: register.handle,
full_name: None,
picture: None,
avatar_asset_id: None,
password_hash,
status: UserStatus::Active,

View file

@ -9,7 +9,7 @@ use utils::parse_basic_auth;
use crate::{
services::{app_session::AppClientSession, session::verify_token},
token_claims::AppUserTokenClaims,
token_claims::OAuth2AccessTokenClaims,
AppState
};
@ -102,14 +102,16 @@ pub async fn enforce_jwt_auth_middleware(
);
}
};
let token_claims: AppUserTokenClaims = match verify_token(&app_state.secrets, jwt) {
Ok(val) => val,
Err(_e) => {
return Err(
(StatusCode::UNAUTHORIZED, Html("Unauthorized: The provided JWT is invalid."))
);
}
};
let token_claims: OAuth2AccessTokenClaims =
match verify_token(&app_state.config, &app_state.secrets, jwt) {
Ok(val) => val,
Err(_e) => {
dbg!(_e);
return Err(
(StatusCode::UNAUTHORIZED, Html("Unauthorized: The provided JWT is invalid."))
);
}
};
req.extensions_mut().insert(token_claims);
Ok(next.run(req).await)
}

View file

@ -28,18 +28,20 @@ pub async fn auth_middleware(
return Ok(next.run(req).await)
}
};
let token_claims: UserTokenClaims = match verify_token(&app_state.secrets, jwt) {
Ok(val) => val,
Err(_e) => {
// UserWebGUI: delete invalid JWT cookie
return Err(
(
cookies.remove(WEB_GUI_JWT_COOKIE_NAME),
Redirect::to(&original_uri.to_string())
)
);
}
};
let token_claims: UserTokenClaims =
match verify_token(&app_state.config, &app_state.secrets, jwt) {
Ok(val) => val,
Err(_e) => {
dbg!(&_e);
// UserWebGUI: delete invalid JWT cookie
return Err(
(
cookies.remove(WEB_GUI_JWT_COOKIE_NAME),
Redirect::to(&original_uri.to_string())
)
);
}
};
req.extensions_mut().insert(token_claims);
Ok(next.run(req).await)
}

View file

@ -47,10 +47,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", get(api::index::get_index))
.route("/api/user-assets/:asset_id", get(api::public_assets::get_user_asset));
let well_known_routes = Router::new()
.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));
Router::new()
.merge(public_routes)

View file

@ -12,9 +12,16 @@ pub fn verify_redirect_uri(app: &Application, input_redirect_uri: &str) -> bool
pub fn parse_scope(scope_str: &str) -> Result<Vec<AuthorizationScope>> {
let mut scopes: Vec<AuthorizationScope> = vec![];
for part in scope_str.split(' ') {
scopes.push(
AuthorizationScope::from_str(part).context("Cannot parse space-delimited scope.")?
)
if part == "openid" {
scopes.push(AuthorizationScope::UserReadBasic);
scopes.push(AuthorizationScope::UserReadRoles);
continue;
}
if part == "profile" || part == "email" {
continue;
}
scopes.push(AuthorizationScope::from_str(part).context("Cannot parse space-delimited scope.")?);
}
dbg!(&scopes);
Ok(scopes)
}

View file

@ -1,24 +1,37 @@
use anyhow::Result;
use serde::{de::DeserializeOwned, Serialize};
use jsonwebtoken::{encode, decode, Header, Algorithm, Validation, EncodingKey, DecodingKey};
use kernel::context::AppSecrets;
use kernel::{context::AppSecrets, models::config::Config};
pub fn create_token<T: Serialize>(secrets: &AppSecrets, claims: T) -> String {
pub fn create_token<T: Serialize>(
_config: &Config,
secrets: &AppSecrets,
claims: T
) -> String {
let token = encode(
&Header::default(),
&Header::new(Algorithm::RS256),
&claims,
&EncodingKey::from_secret(secrets.jwt_secret.as_bytes())
&EncodingKey::from_rsa_pem(&secrets.signing_keypair.1)
.expect("To build encoding key from signing key.")
).expect("Create token");
token
}
pub fn verify_token<T: DeserializeOwned>(secrets: &AppSecrets, jwt: &str) -> Result<T> {
pub fn verify_token<T: DeserializeOwned>(
config: &Config,
secrets: &AppSecrets,
jwt: &str
) -> Result<T> {
let mut validation = Validation::new(Algorithm::RS256);
validation.set_issuer(&[config.instance.base_uri.clone()]);
validation.set_audience(&[config.instance.base_uri.clone()]);
let token_data = decode::<T>(
jwt,
&DecodingKey::from_secret(secrets.jwt_secret.as_bytes()),
&Validation::new(Algorithm::HS256)
&DecodingKey::from_rsa_pem(&secrets.signing_keypair.0)
.expect("To build decoding key from signing key."),
&validation
)?;
Ok(token_data.claims)

View file

@ -55,10 +55,9 @@
/>
</div>
<div class="mb-3">
<label for="picture">Profile picture</label>
<!-- for now, no JPEG -->
<label for="avatar">Profile picture</label>
<input
id="picture" name="picture"
id="avatar" name="avatar"
type="file"
accept="image/gif, image/png, image/jpeg"
class="form-control"

View file

@ -5,9 +5,12 @@
<a href="/me/details-form">Update details.</a>
<a href="/me/authorizations">Manage authorizations.</a>
<p>
{% if user.picture %}
<img src="data:image/*;base64,{{ encode_b64str(user.picture) }}" style="width: 150px; height: 150px; object-fit: contain">
{% if user.avatar_asset_id %}
<div class="my-3">
<img
src="http://localhost:8085/api/user-assets/{{ user.avatar_asset_id }}"
style="width: 150px; height: 150px; object-fit: contain">
</div>
{% endif %}
<ul>
<li>

View file

@ -1,6 +1,6 @@
use fully_pub::fully_pub;
use jsonwebtoken::get_current_timestamp;
use kernel::models::authorization::AuthorizationScope;
use kernel::models::{authorization::AuthorizationScope, config::Config, user::User};
use serde::{Deserialize, Serialize};
use time::Duration;
@ -12,41 +12,91 @@ struct UserTokenClaims {
/// token expiration
exp: u64,
/// token issuer
iss: String
iss: String,
// TODO: add roles
/// token audience
aud: String
}
impl UserTokenClaims {
pub fn new(user_id: &str, max_age: Duration) -> Self {
pub fn new(config: &Config, user_id: &str, max_age: Duration) -> Self {
UserTokenClaims {
sub: user_id.into(),
exp: get_current_timestamp() + max_age.whole_seconds() as u64,
iss: "Minauthator".into()
iss: config.instance.base_uri.clone(),
aud: config.instance.base_uri.clone(),
}
}
}
/// Access token for OAuth2 defined in RFC 9068
/// @See https://datatracker.ietf.org/doc/html/rfc9068
#[derive(Debug, Serialize, Deserialize, Clone)]
#[fully_pub]
struct AppUserTokenClaims {
/// combined subject
client_id: String,
user_id: String,
scopes: Vec<AuthorizationScope>,
/// token expiration
struct OAuth2AccessTokenClaims {
/// Token issuer (URI to the issuer)
iss: String,
/// Audiance (In this case, the audiance is equal to the issuer)
aud: String,
/// End-user id assigned by the issuer (user_id)
sub: String,
/// Token expiration
exp: u64,
/// token issuer
iss: String
/// List of OAuth 2 scopes asked by the client
scopes: Vec<AuthorizationScope>
}
impl AppUserTokenClaims {
pub fn new(client_id: &str, user_id: &str, scopes: Vec<AuthorizationScope>) -> Self {
AppUserTokenClaims {
client_id: client_id.into(),
user_id: user_id.into(),
scopes,
impl OAuth2AccessTokenClaims {
pub fn new(config: &Config, user: &User, scopes: Vec<AuthorizationScope>) -> Self {
OAuth2AccessTokenClaims {
iss: config.instance.base_uri.clone(),
aud: config.instance.base_uri.clone(),
sub: user.id.clone(),
exp: get_current_timestamp() + 86_000,
iss: "Minauth".into()
scopes,
}
}
}
/// @See https://openid.net/specs/openid-connect-core-1_0.html#IDToken
/// @See https://openid.net/specs/openid-connect-core-1_0.html#StandardClaims
#[derive(Debug, Serialize, Deserialize, Clone)]
#[fully_pub]
struct OIDCIdTokenClaims {
/// Token expiration
exp: u64,
/// Token issuer (URI to the issuer)
iss: String,
/// Audiance (client_id)
aud: String,
/// End-user id assigned by the issuer (user_id)
sub: String,
/// additional claims
name: Option<String>,
email: Option<String>,
preferred_username: Option<String>,
roles: Vec<String>,
nonce: Option<String>
}
impl OIDCIdTokenClaims {
pub fn new(
config: &Config,
client_id: &str,
user: User,
nonce: Option<String>
) -> Self {
OIDCIdTokenClaims {
iss: config.instance.base_uri.clone(),
aud: client_id.into(),
sub: user.id,
exp: get_current_timestamp() + 86_000,
email: user.email,
name: user.full_name,
preferred_username: Some(user.handle),
roles: user.roles.0,
nonce
}
}
}

View file

@ -18,6 +18,8 @@ chrono = { workspace = true }
toml = { workspace = true }
sqlx = { workspace = true }
dotenvy = { workspace = true }
sha2 = { workspace = true }
hex-literal = { workspace = true }
uuid = { workspace = true }
url = { workspace = true }

View file

@ -1,4 +1,5 @@
pub const DEFAULT_DB_PATH: &str = "/var/lib/minauthator/minauthator.db";
pub const DEFAULT_ASSETS_PATH: &str = "/usr/local/lib/minauthator/assets";
pub const DEFAULT_CONFIG_PATH: &str = "/etc/minauthator/config.toml";
pub const DEFAULT_SIGNING_KEY_PATH: &str = "/etc/minauthator/secrets/jwt.key.pem";

View file

@ -1,11 +1,13 @@
use std::{env, fs};
use std::{env, fs, path::Path};
use anyhow::{Result, Context, anyhow};
use fully_pub::fully_pub;
use log::info;
use sqlx::{Pool, Sqlite};
use crate::{
consts::{DEFAULT_CONFIG_PATH, DEFAULT_DB_PATH}, database::prepare_database, models::config::Config, repositories::storage::Storage
consts::{DEFAULT_CONFIG_PATH, DEFAULT_DB_PATH, DEFAULT_SIGNING_KEY_PATH},
database::prepare_database,
models::config::Config,
repositories::storage::Storage
};
/// get server config
@ -26,9 +28,18 @@ struct StartKernelConfig {
#[derive(Debug, Clone)]
#[fully_pub]
struct AppSecrets {
jwt_secret: String
/// RSA keypair (public, private) used to signed the JWT issued by minauthator in PEM conainer format
signing_keypair: (Vec<u8>, Vec<u8>)
}
#[derive(Debug, Clone)]
#[fully_pub]
struct ComputedConfig {
signing_public_key: Vec<u8>,
signing_private_key: Vec<u8>
}
#[derive(Debug, Clone)]
#[fully_pub]
struct KernelContext {
@ -37,6 +48,19 @@ struct KernelContext {
storage: Storage
}
fn get_signing_keypair(key_path: &str) -> Result<(Vec<u8>, Vec<u8>)> {
let key_path = Path::new(key_path);
let pub_key_path = key_path.with_extension("pub");
let private_key: Vec<u8> = fs::read_to_string(&key_path)
.context(format!("Failed to read private key from path {:?}.", key_path))?
.as_bytes().to_vec();
let public_key: Vec<u8> = fs::read_to_string(&pub_key_path)
.context(format!("Failed to read public key from path {:?}.", pub_key_path))?
.as_bytes().to_vec();
Ok((public_key, private_key))
}
pub async fn get_kernel_context(start_config: StartKernelConfig) -> Result<KernelContext> {
env_logger::init();
let _ = dotenvy::dotenv();
@ -50,10 +74,13 @@ pub async fn get_kernel_context(start_config: StartKernelConfig) -> Result<Kerne
let config: Config = get_config(config_path)
.expect("Cannot get config.");
let signing_key_path = config.signing_key.clone().unwrap_or(DEFAULT_SIGNING_KEY_PATH.to_string());
// optionally load dotenv file
let _ = dotenvy::dotenv();
let secrets = AppSecrets {
jwt_secret: env::var("APP_JWT_SECRET")
.context("Expected APP_JWT_SECRET environment variable to exists.")?
signing_keypair: get_signing_keypair(&signing_key_path)?
};
Ok(KernelContext {

View file

@ -25,6 +25,8 @@ struct Authorization {
client_id: String,
scopes: Json<Vec<AuthorizationScope>>,
nonce: Option<String>,
/// defined in https://datatracker.ietf.org/doc/html/rfc6749#section-4.1.2
code: String,
last_used_at: Option<DateTime<Utc>>,

View file

@ -66,11 +66,6 @@ struct Role {
struct Config {
instance: InstanceConfig,
applications: Vec<Application>,
roles: Vec<Role>
}
#[derive(Debug, Clone)]
#[fully_pub]
struct AppSecrets {
jwt_secret: String
roles: Vec<Role>,
signing_key: Option<String>
}

View file

@ -1,3 +1,4 @@
pub mod config;
pub mod user;
pub mod user_asset;
pub mod authorization;

View file

@ -14,7 +14,7 @@ enum UserStatus {
Active
}
#[derive(sqlx::FromRow, Deserialize, Serialize, Debug)]
#[derive(sqlx::FromRow, Deserialize, Serialize, Debug, Clone)]
#[fully_pub]
struct User {
/// uuid
@ -23,7 +23,7 @@ struct User {
full_name: Option<String>,
email: Option<String>,
website: Option<String>,
picture: Option<Vec<u8>>, // embeded blob to store profile pic
avatar_asset_id: Option<String>,
password_hash: Option<String>, // argon2 password hash
status: UserStatus,
roles: Json<Vec<String>>,
@ -43,7 +43,7 @@ impl User {
full_name: None,
email: None,
website: None,
picture: None,
avatar_asset_id: None,
password_hash: None,
status: UserStatus::Disabled,
roles: Json(Vec::new()),

View file

@ -0,0 +1,41 @@
use fully_pub::fully_pub;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use sha2::{Sha256, Digest};
use super::user::User;
#[derive(sqlx::FromRow, Deserialize, Serialize, Debug)]
#[fully_pub]
struct UserAsset {
/// uuid
id: String,
user_id: String,
mime_type: String,
fingerprint: String,
name: Option<String>,
content: Vec<u8>,
created_at: DateTime<Utc>
}
impl UserAsset {
pub fn new(
user: &User,
content: Vec<u8>,
mime_type: String,
name: Option<String>
) -> UserAsset {
let digest = Sha256::digest(&content);
UserAsset {
id: Uuid::new_v4().to_string(),
user_id: user.id.clone(),
fingerprint: format!("{:x}", digest),
mime_type,
content,
name,
created_at: Utc::now()
}
}
}

View file

@ -1,6 +1,6 @@
// user repositories
use crate::models::user::User;
use crate::models::{user::User, user_asset::UserAsset};
use super::storage::Storage;
use anyhow::{Result, Context};
@ -19,3 +19,30 @@ pub async fn get_users(storage: &Storage) -> Result<Vec<User>> {
.await
.context("To get users.")
}
pub async fn create_user_asset(storage: &Storage, asset: UserAsset) -> Result<()> {
sqlx::query("INSERT INTO user_assets
(id, user_id, mime_type, fingerprint, content, created_at)
VALUES ($1, $2, $3, $4, $5, $6)
")
.bind(&asset.id)
.bind(&asset.user_id)
.bind(&asset.mime_type)
.bind(&asset.fingerprint)
.bind(&asset.content)
.bind(&asset.created_at)
.execute(&storage.0)
.await
.context("While inserting user asset.")?;
// .bind(details_update.avatar.contents.to_vec())
Ok(())
}
pub async fn get_user_asset_by_id(storage: &Storage, id: &str) -> Result<UserAsset> {
sqlx::query_as("SELECT * FROM user_assets WHERE id = $1")
.bind(id)
.fetch_one(&storage.0)
.await
.context("To get user asset by id.")
}

View file

@ -5,8 +5,8 @@ CREATE TABLE users (
full_name TEXT,
email TEXT UNIQUE,
website TEXT,
picture BLOB,
roles TEXT NOT NULL, -- json array of user roles
avatar_asset_id TEXT,
status TEXT CHECK(status IN ('Invited', 'Active', 'Disabled')) NOT NULL DEFAULT 'Disabled',
password_hash TEXT,
@ -15,6 +15,17 @@ CREATE TABLE users (
created_at DATETIME NOT NULL
);
DROP TABLE IF EXISTS user_assets;
CREATE TABLE user_assets (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
mime_type TEXT NOT NULL,
fingerprint TEXT NOT NULL,
name TEXT, -- file name
content BLOB NOT NULL,
created_at DATETIME NOT NULL
);
DROP TABLE IF EXISTS authorizations;
CREATE TABLE authorizations (
id TEXT PRIMARY KEY,
@ -22,6 +33,7 @@ CREATE TABLE authorizations (
client_id TEXT NOT NULL,
scopes TEXT, -- json array of app scope (permissions)
code TEXT,
nonce TEXT, -- code used to associate client session to id_token
last_used_at DATETIME,
created_at DATETIME NOT NULL

View file

@ -0,0 +1,58 @@
signing_key = "tmp/secrets/signing.key"
[instance]
base_uri = "http://localhost:8086"
name = "Example org"
logo_uri = "https://example.org/logo.png"
[[applications]]
slug = "demo_app"
name = "Demo app"
description = "A super application where you can do everything you want."
client_id = "00000001-0000-0000-0000-000000000001"
client_secret = "dummy_client_secret"
login_uri = "https://localhost:9876"
allowed_redirect_uris = [
"http://localhost:9090/callback",
"http://localhost:9876/callback"
]
visibility = "Internal"
authorize_flow = "Implicit"
[[applications]]
slug = "wiki"
name = "Wiki app"
description = "The knowledge base of the exemple org."
client_id = "f9de1885-448d-44bb-8c48-7e985486a8c6"
client_secret = "49c6c16a-0a8a-4981-a60d-5cb96582cc1a"
login_uri = "https://wiki.example.org/login"
allowed_redirect_uris = [
"https://wiki.example.org/oauth2/callback"
]
visibility = "Public"
authorize_flow = "Implicit"
[[applications]]
slug = "private_app"
name = "Demo app"
description = "Private app you should never discover"
client_id = "c8a08783-2342-4ce3-a3cb-9dc89b6bdf"
client_secret = "this_is_the_secret"
login_uri = "https://private-app.org"
allowed_redirect_uris = [
"http://localhost:9091/authorize",
]
visibility = "Private"
authorize_flow = "Implicit"
[[roles]]
slug = "basic"
name = "Basic"
description = "Basic user"
default = true
[[roles]]
slug = "admin"
name = "Administrator"
description = "Full power on organization instance"

View file

@ -0,0 +1,11 @@
#!/usr/bin/bash
password_hash="$(echo -n "root" | argon2 salt_06cGGWYDJCZ -e)"
echo $password_hash
SQL=$(cat <<EOF
INSERT INTO users
(id, handle, email, roles, status, password_hash, created_at)
VALUES
('$(uuid)', 'john.doe', 'john.doe@example.org', '[]', 'Active', '$password_hash', '2024-11-30T00:00:00Z');
EOF)
echo $SQL | sqlite3 $DB_PATH

View file

@ -0,0 +1,41 @@
POST {{ base_url }}/login
[FormParams]
login: john.doe
password: root
HTTP 303
[Captures]
user_jwt: cookie "minauthator_jwt"
# OAuth2 implicit flow (pre-granted app)
GET {{ base_url }}/authorize
[QueryStringParams]
client_id: 00000001-0000-0000-0000-000000000001
response_type: code
redirect_uri: http://localhost:9090/callback
state: Afk4kf6pbZkms78jM
scope: openid profile email
HTTP 302
[Captures]
authorization_code: header "Location" regex "\\?code=(.*)&"
# OIDC Token exchange
POST {{ base_url }}/api/token
[BasicAuth]
00000001-0000-0000-0000-000000000001: dummy_client_secret
[FormParams]
code: {{ authorization_code }}
scope: user_read_basic
redirect_uri: http://localhost:9090/callback
grant_type: authorization_code
HTTP 200
Content-Type: application/json
[Asserts]
jsonpath "$.access_token" exists
jsonpath "$.id_token" exists
jsonpath "$.id_token" matches "eyJ[[:alpha:]0-9].[[:alpha:]0-9].[[:alpha:]0-9]"
[Captures]
id_token: jsonpath "$.id_token"
# TODO: assert id_token JWT claims fields
# TODO: contribute to hurl to add JWT extraction and assertion
# See. https://github.com/Orange-OpenSource/hurl/issues/2223

View file

@ -1,3 +1,5 @@
signing_key = "tmp/secrets/signing.key"
[instance]
base_uri = "http://localhost:8086"
name = "Example org"

View file

@ -27,7 +27,7 @@ handle: root
email: root@johndoe.net
full_name: John Doe
website: https://johndoe.net
picture: file,john_doe_profile_pic.jpg; image/jpeg
avatar: file,john_doe_profile_pic.jpg; image/jpeg
HTTP 200
GET {{ base_url }}/me/authorizations

View file

@ -1,3 +1,4 @@
signing_key = "tmp/secrets/signing.key"
applications = []
roles = []