diff --git a/.env b/.env index 8c4c540..e69de29 100644 --- a/.env +++ b/.env @@ -1 +0,0 @@ -APP_JWT_SECRET=bc1996ea-5464-424a-9a38-5604f2bc865a diff --git a/.swp b/.swp new file mode 100644 index 0000000..6f58751 Binary files /dev/null and b/.swp differ diff --git a/Cargo.lock b/Cargo.lock index fca0ec9..9b04e92 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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" @@ -966,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", @@ -1238,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" @@ -1246,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]] @@ -1561,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" @@ -1921,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" diff --git a/TODO.md b/TODO.md index 6d8df20..5771e72 100644 --- a/TODO.md +++ b/TODO.md @@ -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 @@ -50,3 +53,5 @@ - [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 diff --git a/config.toml b/config.toml index 2b97bd9..12f31b4 100644 --- a/config.toml +++ b/config.toml @@ -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" diff --git a/justfile b/justfile index c3c20d9..70b755d 100644 --- a/justfile +++ b/justfile @@ -2,17 +2,17 @@ export RUST_BACKTRACE := "1" export RUST_LOG := "trace" 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 diff --git a/lib/http_server/Cargo.toml b/lib/http_server/Cargo.toml index 260d58c..7fc709b 100644 --- a/lib/http_server/Cargo.toml +++ b/lib/http_server/Cargo.toml @@ -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" diff --git a/lib/http_server/src/controllers/api/oauth2/access_token.rs b/lib/http_server/src/controllers/api/oauth2/access_token.rs index 1da6993..396ea96 100644 --- a/lib/http_server/src/controllers/api/oauth2/access_token.rs +++ b/lib/http_server/src/controllers/api/oauth2/access_token.rs @@ -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() diff --git a/lib/http_server/src/controllers/api/openid/keys.rs b/lib/http_server/src/controllers/api/openid/keys.rs new file mode 100644 index 0000000..8943483 --- /dev/null +++ b/lib/http_server/src/controllers/api/openid/keys.rs @@ -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 +} + +pub async fn get_signing_public_keys( + State(app_state): State, +) -> 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() +} diff --git a/lib/http_server/src/controllers/api/openid/mod.rs b/lib/http_server/src/controllers/api/openid/mod.rs index 1ab9853..063a9c9 100644 --- a/lib/http_server/src/controllers/api/openid/mod.rs +++ b/lib/http_server/src/controllers/api/openid/mod.rs @@ -1 +1,2 @@ pub mod well_known; +pub mod keys; diff --git a/lib/http_server/src/controllers/api/openid/well_known.rs b/lib/http_server/src/controllers/api/openid/well_known.rs index 54daf3e..6b2d0e8 100644 --- a/lib/http_server/src/controllers/api/openid/well_known.rs +++ b/lib/http_server/src/controllers/api/openid/well_known.rs @@ -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, response_types_supported: Vec, - token_endpoint_auth_methods_supported: Vec + token_endpoint_auth_methods_supported: Vec, + id_token_signing_alg_values_supported: Vec, + 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 }) } diff --git a/lib/http_server/src/controllers/api/read_user.rs b/lib/http_server/src/controllers/api/read_user.rs index 4b9e7c1..465a307 100644 --- a/lib/http_server/src/controllers/api/read_user.rs +++ b/lib/http_server/src/controllers/api/read_user.rs @@ -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, - Extension(token_claims): Extension, + Extension(token_claims): Extension, ) -> 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"); diff --git a/lib/http_server/src/controllers/ui/authorize.rs b/lib/http_server/src/controllers/ui/authorize.rs index f6016c9..923d8a3 100644 --- a/lib/http_server/src/controllers/ui/authorize.rs +++ b/lib/http_server/src/controllers/ui/authorize.rs @@ -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 } 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 ) -> 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, Form(authorize_form): Form ) -> 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) diff --git a/lib/http_server/src/controllers/ui/login.rs b/lib/http_server/src/controllers/ui/login.rs index 59cce05..68061d4 100644 --- a/lib/http_server/src/controllers/ui/login.rs +++ b/lib/http_server/src/controllers/ui/login.rs @@ -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 diff --git a/lib/http_server/src/middlewares/app_auth.rs b/lib/http_server/src/middlewares/app_auth.rs index 3709f8b..c5c4e56 100644 --- a/lib/http_server/src/middlewares/app_auth.rs +++ b/lib/http_server/src/middlewares/app_auth.rs @@ -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) } diff --git a/lib/http_server/src/middlewares/user_auth.rs b/lib/http_server/src/middlewares/user_auth.rs index 7582eb7..921bdce 100644 --- a/lib/http_server/src/middlewares/user_auth.rs +++ b/lib/http_server/src/middlewares/user_auth.rs @@ -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) } diff --git a/lib/http_server/src/router.rs b/lib/http_server/src/router.rs index 7901928..fcbd89a 100644 --- a/lib/http_server/src/router.rs +++ b/lib/http_server/src/router.rs @@ -51,7 +51,8 @@ pub fn build_router(server_config: &ServerConfig, app_state: AppState) -> Router .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) diff --git a/lib/http_server/src/services/oauth2.rs b/lib/http_server/src/services/oauth2.rs index ed60954..d528747 100644 --- a/lib/http_server/src/services/oauth2.rs +++ b/lib/http_server/src/services/oauth2.rs @@ -12,9 +12,16 @@ pub fn verify_redirect_uri(app: &Application, input_redirect_uri: &str) -> bool pub fn parse_scope(scope_str: &str) -> Result> { let mut scopes: Vec = 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) } diff --git a/lib/http_server/src/services/session.rs b/lib/http_server/src/services/session.rs index 018e094..dd97828 100644 --- a/lib/http_server/src/services/session.rs +++ b/lib/http_server/src/services/session.rs @@ -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(secrets: &AppSecrets, claims: T) -> String { +pub fn create_token( + _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(secrets: &AppSecrets, jwt: &str) -> Result { +pub fn verify_token( + config: &Config, + secrets: &AppSecrets, + jwt: &str +) -> Result { + 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::( 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) diff --git a/lib/http_server/src/token_claims.rs b/lib/http_server/src/token_claims.rs index 932b4ce..726c4e5 100644 --- a/lib/http_server/src/token_claims.rs +++ b/lib/http_server/src/token_claims.rs @@ -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, - /// 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 } -impl AppUserTokenClaims { - pub fn new(client_id: &str, user_id: &str, scopes: Vec) -> Self { - AppUserTokenClaims { - client_id: client_id.into(), - user_id: user_id.into(), - scopes, +impl OAuth2AccessTokenClaims { + pub fn new(config: &Config, user: &User, scopes: Vec) -> 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, + email: Option, + preferred_username: Option, + roles: Vec, + nonce: Option +} + +impl OIDCIdTokenClaims { + pub fn new( + config: &Config, + client_id: &str, + user: User, + nonce: Option + ) -> 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 } } } diff --git a/lib/kernel/src/consts.rs b/lib/kernel/src/consts.rs index 31fca20..9bdfaf2 100644 --- a/lib/kernel/src/consts.rs +++ b/lib/kernel/src/consts.rs @@ -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"; diff --git a/lib/kernel/src/context.rs b/lib/kernel/src/context.rs index 0c6e3b1..cb90e0d 100644 --- a/lib/kernel/src/context.rs +++ b/lib/kernel/src/context.rs @@ -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, Vec) } +#[derive(Debug, Clone)] +#[fully_pub] +struct ComputedConfig { + signing_public_key: Vec, + signing_private_key: Vec +} + + #[derive(Debug, Clone)] #[fully_pub] struct KernelContext { @@ -37,6 +48,19 @@ struct KernelContext { storage: Storage } +fn get_signing_keypair(key_path: &str) -> Result<(Vec, Vec)> { + let key_path = Path::new(key_path); + let pub_key_path = key_path.with_extension("pub"); + let private_key: Vec = 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 = 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 { env_logger::init(); let _ = dotenvy::dotenv(); @@ -50,10 +74,13 @@ pub async fn get_kernel_context(start_config: StartKernelConfig) -> Result>, + nonce: Option, + /// defined in https://datatracker.ietf.org/doc/html/rfc6749#section-4.1.2 code: String, last_used_at: Option>, diff --git a/lib/kernel/src/models/config.rs b/lib/kernel/src/models/config.rs index 57a275a..bf2d311 100644 --- a/lib/kernel/src/models/config.rs +++ b/lib/kernel/src/models/config.rs @@ -66,11 +66,6 @@ struct Role { struct Config { instance: InstanceConfig, applications: Vec, - roles: Vec -} - -#[derive(Debug, Clone)] -#[fully_pub] -struct AppSecrets { - jwt_secret: String + roles: Vec, + signing_key: Option } diff --git a/lib/kernel/src/models/user.rs b/lib/kernel/src/models/user.rs index 9634fa0..355fcdd 100644 --- a/lib/kernel/src/models/user.rs +++ b/lib/kernel/src/models/user.rs @@ -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 diff --git a/migrations/all.sql b/migrations/all.sql index 2f71c3c..adf3327 100644 --- a/migrations/all.sql +++ b/migrations/all.sql @@ -33,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 diff --git a/tests/hurl_integration/oidc_core/config.toml b/tests/hurl_integration/oidc_core/config.toml new file mode 100644 index 0000000..df607aa --- /dev/null +++ b/tests/hurl_integration/oidc_core/config.toml @@ -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" + diff --git a/tests/hurl_integration/oidc_core/init_db.sh b/tests/hurl_integration/oidc_core/init_db.sh new file mode 100755 index 0000000..b57b134 --- /dev/null +++ b/tests/hurl_integration/oidc_core/init_db.sh @@ -0,0 +1,11 @@ +#!/usr/bin/bash + +password_hash="$(echo -n "root" | argon2 salt_06cGGWYDJCZ -e)" +echo $password_hash +SQL=$(cat <