WIP: feat: support OIDC id_token

This commit is contained in:
Matthieu Bessat 2024-12-12 01:12:40 +01:00
parent ca84a0f99f
commit af6904002b
23 changed files with 167 additions and 60 deletions

1
.env
View file

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

BIN
.swp Normal file

Binary file not shown.

View file

@ -1,5 +1,8 @@
# TODO # TODO
- [ ] better OIDC support
- [ ] better support of `profile` `openid` `email` `roles` scopes
- [ ] i18n strings in the http website. - [ ] i18n strings in the http website.
- [ ] Instance customization support - [ ] Instance customization support

View file

@ -1,3 +1,5 @@
signing_key = "tmp/secrets/signing.key"
[instance] [instance]
base_uri = "https://auth.fictive.org" base_uri = "https://auth.fictive.org"
name = "Fictive's auth" name = "Fictive's auth"

View file

@ -6,7 +6,7 @@ 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};
use crate::{ use crate::{
services::{app_session::AppClientSession, session::create_token}, token_claims::{AppUserTokenClaims, OAuth2AccessTokenClaims, OIDCIdTokenClaims}, AppState services::{app_session::AppClientSession, session::create_token}, token_claims::{OAuth2AccessTokenClaims, OIDCIdTokenClaims}, AppState
}; };
const AUTHORIZATION_CODE_TTL_SECONDS: i64 = 120; const AUTHORIZATION_CODE_TTL_SECONDS: i64 = 120;
@ -81,16 +81,17 @@ pub async fn get_access_token(
// 3.1. Generate JWT for OAuth2 client user session // 3.1. Generate JWT for OAuth2 client user session
let access_token_jwt = create_token( let access_token_jwt = create_token(
&app_state.config,
&app_state.secrets, &app_state.secrets,
OAuth2AccessTokenClaims::new( OAuth2AccessTokenClaims::new(
&app_state.config, &app_state.config,
&app_client_session.client_id,
&user, &user,
authorization.scopes.to_vec() authorization.scopes.to_vec()
) )
); );
// 3.2. Generate id_token for OIDC client // 3.2. Generate id_token for OIDC client
let id_token_jwt = create_token( let id_token_jwt = create_token(
&app_state.config,
&app_state.secrets, &app_state.secrets,
OIDCIdTokenClaims::new( OIDCIdTokenClaims::new(
&app_state.config, &app_state.config,

View file

@ -0,0 +1,50 @@
use axum::{extract::State, response::IntoResponse, Json};
use fully_pub::fully_pub;
use kernel::models::authorization::AuthorizationScope;
use serde::Serialize;
use strum::IntoEnumIterator;
use crate::AppState;
/// JSON Web Key
/// @See https://www.rfc-editor.org/rfc/rfc7517.html
#[derive(Serialize)]
#[fully_pub]
struct JWK {
kty: String,
crv: String,
alg: String,
key_ops: Vec<String>
}
/// JSON Web Key set
/// @See https://www.rfc-editor.org/rfc/rfc7517.html
#[derive(Serialize)]
#[fully_pub]
struct JWKs {
keys: Vec<JWK>
}
pub async fn get_signing_public_key(
State(app_state): State<AppState>,
) -> impl IntoResponse {
let base_url = app_state.config.instance.base_uri;
Json(vec![
JWK {
}
])
// {"kty":"RSA",
// "n": "0vx7agoebGcQSuuPiLJXZptN9nndrQmbXEps2aiAFbWhM78LhWx
// 4cbbfAAtVT86zwu1RK7aPFFxuhDR1L6tSoc_BJECPebWKRXjBZCiFV4n3oknjhMs
// tn64tZ_2W-5JsGY4Hc5n9yBXArwl93lqt7_RN5w6Cf0h4QyQ5v-65YGjQR0_FDW2
// QvzqY368QQMicAtaSqzs8KJZgnYb9c7d0zgdAZHzu6qMQvRL5hajrn1n91CbOpbI
// SD08qNLyrdkt-bFTWhAI4vMQFh6WeZu0fM4lFd2NcRwr3XPksINHaQ-G_xBniIqb
// w0Ls1jF44-csFCur-kEgU8awapJzKnqDKgw",
// "e":"AQAB",
// "alg":"RS256",
// "kid":"2011-04-29"}
}

View file

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

View file

@ -18,7 +18,8 @@ struct WellKnownOpenIdConfiguration {
scopes_supported: Vec<String>, scopes_supported: Vec<String>,
response_types_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> id_token_signing_alg_values_supported: Vec<String>,
jwks_uri: String
} }
pub async fn get_well_known_openid_configuration( pub async fn get_well_known_openid_configuration(
@ -33,7 +34,8 @@ pub async fn get_well_known_openid_configuration(
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()],
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!["HS256".into()], id_token_signing_alg_values_supported: vec!["RS256".into()],
jwks_uri: format!("{}/.well-known/jwks", base_url)
// jwks_uri: // jwks_uri:
// subject_types_supported // subject_types_supported
}) })

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,24 +1,37 @@
use anyhow::Result; use anyhow::Result;
use serde::{de::DeserializeOwned, Serialize}; use serde::{de::DeserializeOwned, Serialize};
use jsonwebtoken::{encode, decode, Header, Algorithm, Validation, EncodingKey, DecodingKey}; 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( let token = encode(
&Header::default(), &Header::new(Algorithm::RS256),
&claims, &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"); ).expect("Create token");
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>( let token_data = decode::<T>(
jwt, jwt,
&DecodingKey::from_secret(secrets.jwt_secret.as_bytes()), &DecodingKey::from_rsa_pem(&secrets.signing_keypair.0)
&Validation::new(Algorithm::HS256) .expect("To build decoding key from signing key."),
&validation
)?; )?;
Ok(token_data.claims) Ok(token_data.claims)

View file

@ -12,16 +12,19 @@ struct UserTokenClaims {
/// token expiration /// token expiration
exp: u64, exp: u64,
/// token issuer /// token issuer
iss: String iss: String,
// TODO: add roles // TODO: add roles
/// token audience
aud: String
} }
impl UserTokenClaims { 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 { UserTokenClaims {
sub: user_id.into(), sub: user_id.into(),
exp: get_current_timestamp() + max_age.whole_seconds() as u64, 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(),
} }
} }
} }
@ -33,7 +36,7 @@ impl UserTokenClaims {
struct OAuth2AccessTokenClaims { struct OAuth2AccessTokenClaims {
/// Token issuer (URI to the issuer) /// Token issuer (URI to the issuer)
iss: String, iss: String,
/// Audiance (client_id) /// Audiance (In this case, the audiance is equal to the issuer)
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,
@ -44,10 +47,10 @@ struct OAuth2AccessTokenClaims {
} }
impl OAuth2AccessTokenClaims { impl OAuth2AccessTokenClaims {
pub fn new(config: &Config, client_id: &str, user: &User, scopes: Vec<AuthorizationScope>) -> Self { pub fn new(config: &Config, user: &User, scopes: Vec<AuthorizationScope>) -> Self {
OAuth2AccessTokenClaims { OAuth2AccessTokenClaims {
iss: config.instance.base_uri.clone(), iss: config.instance.base_uri.clone(),
aud: client_id.into(), aud: config.instance.base_uri.clone(),
sub: user.id.clone(), sub: user.id.clone(),
exp: get_current_timestamp() + 86_000, exp: get_current_timestamp() + 86_000,
scopes, scopes,

View file

@ -1,4 +1,5 @@
pub const DEFAULT_DB_PATH: &str = "/var/lib/minauthator/minauthator.db"; 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_ASSETS_PATH: &str = "/usr/local/lib/minauthator/assets";
pub const DEFAULT_CONFIG_PATH: &str = "/etc/minauthator/config.toml"; 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 anyhow::{Result, Context, anyhow};
use fully_pub::fully_pub; use fully_pub::fully_pub;
use log::info; use log::info;
use sqlx::{Pool, Sqlite};
use crate::{ 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 /// get server config
@ -26,9 +28,18 @@ struct StartKernelConfig {
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
#[fully_pub] #[fully_pub]
struct AppSecrets { 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)] #[derive(Debug, Clone)]
#[fully_pub] #[fully_pub]
struct KernelContext { struct KernelContext {
@ -37,6 +48,19 @@ struct KernelContext {
storage: Storage 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> { pub async fn get_kernel_context(start_config: StartKernelConfig) -> Result<KernelContext> {
env_logger::init(); env_logger::init();
let _ = dotenvy::dotenv(); 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) let config: Config = get_config(config_path)
.expect("Cannot get config."); .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 _ = dotenvy::dotenv();
let secrets = AppSecrets { let secrets = AppSecrets {
jwt_secret: env::var("APP_JWT_SECRET") signing_keypair: get_signing_keypair(&signing_key_path)?
.context("Expected APP_JWT_SECRET environment variable to exists.")?
}; };
Ok(KernelContext { Ok(KernelContext {

View file

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

View file

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

View file

@ -1,6 +1,6 @@
POST {{ base_url }}/login POST {{ base_url }}/login
[FormParams] [FormParams]
login: root login: john.doe
password: root password: root
HTTP 303 HTTP 303
[Captures] [Captures]

View file

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

View file

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

View file

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