Compare commits
2 commits
master
...
feat_oidc_
Author | SHA1 | Date | |
---|---|---|---|
02e16a7e74 | |||
4763915812 |
32 changed files with 469 additions and 103 deletions
1
.env
1
.env
|
@ -1 +0,0 @@
|
||||||
APP_JWT_SECRET=bc1996ea-5464-424a-9a38-5604f2bc865a
|
|
BIN
.swp
Normal file
BIN
.swp
Normal file
Binary file not shown.
49
Cargo.lock
generated
49
Cargo.lock
generated
|
@ -349,6 +349,12 @@ dependencies = [
|
||||||
"windows-targets 0.52.6",
|
"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]]
|
[[package]]
|
||||||
name = "base64"
|
name = "base64"
|
||||||
version = "0.21.7"
|
version = "0.21.7"
|
||||||
|
@ -966,11 +972,13 @@ dependencies = [
|
||||||
"chrono",
|
"chrono",
|
||||||
"env_logger",
|
"env_logger",
|
||||||
"fully_pub",
|
"fully_pub",
|
||||||
|
"jsonwebkey-convert",
|
||||||
"jsonwebtoken",
|
"jsonwebtoken",
|
||||||
"kernel",
|
"kernel",
|
||||||
"log",
|
"log",
|
||||||
"minijinja",
|
"minijinja",
|
||||||
"minijinja-embed",
|
"minijinja-embed",
|
||||||
|
"pem 3.0.4",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"serde_urlencoded",
|
"serde_urlencoded",
|
||||||
|
@ -1238,6 +1246,20 @@ dependencies = [
|
||||||
"wasm-bindgen",
|
"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]]
|
[[package]]
|
||||||
name = "jsonwebtoken"
|
name = "jsonwebtoken"
|
||||||
version = "9.3.0"
|
version = "9.3.0"
|
||||||
|
@ -1246,11 +1268,11 @@ checksum = "b9ae10193d25051e74945f1ea2d0b42e03cc3b890f7e4cc5faa44997d808193f"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"base64 0.21.7",
|
"base64 0.21.7",
|
||||||
"js-sys",
|
"js-sys",
|
||||||
"pem",
|
"pem 3.0.4",
|
||||||
"ring",
|
"ring",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"simple_asn1",
|
"simple_asn1 0.6.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
@ -1561,6 +1583,17 @@ version = "1.0.15"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a"
|
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]]
|
[[package]]
|
||||||
name = "pem"
|
name = "pem"
|
||||||
version = "3.0.4"
|
version = "3.0.4"
|
||||||
|
@ -1921,6 +1954,18 @@ dependencies = [
|
||||||
"rand_core",
|
"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]]
|
[[package]]
|
||||||
name = "simple_asn1"
|
name = "simple_asn1"
|
||||||
version = "0.6.2"
|
version = "0.6.2"
|
||||||
|
|
5
TODO.md
5
TODO.md
|
@ -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
|
||||||
|
@ -50,3 +53,5 @@
|
||||||
- [X] basic docker setup
|
- [X] basic docker setup
|
||||||
- [ ] make `docker stop` working (handle SIGTERM/SIGINT)
|
- [ ] make `docker stop` working (handle SIGTERM/SIGINT)
|
||||||
- [ ] 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
|
||||||
|
|
19
config.toml
19
config.toml
|
@ -1,8 +1,23 @@
|
||||||
|
signing_key = "tmp/secrets/signing.key"
|
||||||
|
|
||||||
[instance]
|
[instance]
|
||||||
base_uri = "http://localhost:8085"
|
base_uri = "https://auth.fictive.org"
|
||||||
name = "Example org"
|
name = "Fictive's auth"
|
||||||
logo_uri = "https://example.org/logo.png"
|
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]]
|
[[applications]]
|
||||||
slug = "demo_app"
|
slug = "demo_app"
|
||||||
name = "Demo app"
|
name = "Demo app"
|
||||||
|
|
20
justfile
20
justfile
|
@ -2,17 +2,17 @@ export RUST_BACKTRACE := "1"
|
||||||
export RUST_LOG := "trace"
|
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:
|
watch-server *args:
|
||||||
cargo-watch -x "run --bin minauthator-server -- $CONTEXT_ARGS"
|
cargo-watch -x "run --bin minauthator-server -- $CONTEXT_ARGS {{args}}"
|
||||||
|
|
||||||
server:
|
server *args:
|
||||||
cargo run --bin minauthator-server -- $CONTEXT_ARGS
|
cargo run --bin minauthator-server -- $CONTEXT_ARGS {{args}}
|
||||||
|
|
||||||
admin:
|
admin *args:
|
||||||
cargo run --bin minauthator-admin -- $CONTEXT_ARGS
|
cargo run --bin minauthator-admin -- $CONTEXT_ARGS {{args}}
|
||||||
|
|
||||||
docker-build:
|
docker-build *args:
|
||||||
docker build -t lefuturiste/minauthator .
|
docker build -t lefuturiste/minauthator {{args}} .
|
||||||
|
|
||||||
docker-init-db:
|
docker-init-db:
|
||||||
docker run \
|
docker run \
|
||||||
|
@ -28,6 +28,6 @@ docker-run:
|
||||||
-v minauthator-db:/var/lib/minauthator \
|
-v minauthator-db:/var/lib/minauthator \
|
||||||
lefuturiste/minauthator
|
lefuturiste/minauthator
|
||||||
|
|
||||||
init-db:
|
init-db *args:
|
||||||
sqlite3 -echo tmp/dbs/minauthator.db < migrations/all.sql
|
sqlite3 {{args}} tmp/dbs/minauthator.db < migrations/all.sql
|
||||||
|
|
||||||
|
|
|
@ -43,6 +43,15 @@ argh = { workspace = true }
|
||||||
sqlx = { workspace = true }
|
sqlx = { workspace = true }
|
||||||
uuid = { workspace = true }
|
uuid = { workspace = true }
|
||||||
url = { 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]
|
[build-dependencies]
|
||||||
minijinja-embed = "2.3.1"
|
minijinja-embed = "2.3.1"
|
||||||
|
|
|
@ -4,9 +4,9 @@ use fully_pub::fully_pub;
|
||||||
use log::error;
|
use log::error;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
use kernel::models::authorization::Authorization;
|
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, 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;
|
||||||
|
@ -22,6 +22,7 @@ struct AccessTokenRequestParams {
|
||||||
#[derive(Serialize, Deserialize)]
|
#[derive(Serialize, Deserialize)]
|
||||||
#[fully_pub]
|
#[fully_pub]
|
||||||
struct AccessTokenResponse {
|
struct AccessTokenResponse {
|
||||||
|
id_token: String,
|
||||||
access_token: String,
|
access_token: String,
|
||||||
token_type: String,
|
token_type: String,
|
||||||
expires_in: u64
|
expires_in: u64
|
||||||
|
@ -60,6 +61,7 @@ pub async fn get_access_token(
|
||||||
).into_response();
|
).into_response();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 2.2. Validate that the authorization code is not expired
|
// 2.2. Validate that the authorization code is not expired
|
||||||
let is_code_valid = authorization.last_used_at
|
let is_code_valid = authorization.last_used_at
|
||||||
.map_or(false, |ts| {
|
.map_or(false, |ts| {
|
||||||
|
@ -72,19 +74,38 @@ pub async fn get_access_token(
|
||||||
).into_response();
|
).into_response();
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Generate JWT for oauth2 client user session
|
// 2.3. Fetch user resource owner
|
||||||
let jwt = create_token(
|
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,
|
&app_state.secrets,
|
||||||
AppUserTokenClaims::new(
|
OAuth2AccessTokenClaims::new(
|
||||||
&app_client_session.client_id,
|
&app_state.config,
|
||||||
&authorization.user_id,
|
&user,
|
||||||
authorization.scopes.to_vec()
|
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
|
// 4. return JWT
|
||||||
let access_token_res = AccessTokenResponse {
|
let access_token_res = AccessTokenResponse {
|
||||||
access_token: jwt,
|
id_token: id_token_jwt,
|
||||||
token_type: "jwt".to_string(),
|
access_token: access_token_jwt,
|
||||||
|
token_type: "Bearer".to_string(),
|
||||||
expires_in: 3600
|
expires_in: 3600
|
||||||
};
|
};
|
||||||
Json(access_token_res).into_response()
|
Json(access_token_res).into_response()
|
||||||
|
|
45
lib/http_server/src/controllers/api/openid/keys.rs
Normal file
45
lib/http_server/src/controllers/api/openid/keys.rs
Normal 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()
|
||||||
|
}
|
|
@ -1 +1,2 @@
|
||||||
pub mod well_known;
|
pub mod well_known;
|
||||||
|
pub mod keys;
|
||||||
|
|
|
@ -6,6 +6,8 @@ use strum::IntoEnumIterator;
|
||||||
|
|
||||||
use crate::AppState;
|
use crate::AppState;
|
||||||
|
|
||||||
|
/// Manifest used by OpenID Connect clients
|
||||||
|
/// @See https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderMetadata
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize)]
|
||||||
#[fully_pub]
|
#[fully_pub]
|
||||||
struct WellKnownOpenIdConfiguration {
|
struct WellKnownOpenIdConfiguration {
|
||||||
|
@ -15,7 +17,9 @@ 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>,
|
||||||
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(
|
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(),
|
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!["RS256".into()],
|
||||||
|
jwks_uri: format!("{}/.well-known/jwks", base_url)
|
||||||
|
// jwks_uri:
|
||||||
|
// subject_types_supported
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
|
@ -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");
|
||||||
|
|
|
@ -7,9 +7,7 @@ use serde::{Deserialize, Serialize};
|
||||||
use url::Url;
|
use url::Url;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use kernel::{
|
use kernel::models::{authorization::Authorization, config::AppAuthorizeFlow};
|
||||||
models::{authorization::Authorization, config::AppAuthorizeFlow}
|
|
||||||
};
|
|
||||||
use utils::get_random_alphanumerical;
|
use utils::get_random_alphanumerical;
|
||||||
use crate::{
|
use crate::{
|
||||||
renderer::TemplateRenderer, services::oauth2::{parse_scope, verify_redirect_uri}, token_claims::UserTokenClaims, AppState
|
renderer::TemplateRenderer, services::oauth2::{parse_scope, verify_redirect_uri}, token_claims::UserTokenClaims, AppState
|
||||||
|
@ -25,6 +23,7 @@ struct AuthorizationParams {
|
||||||
redirect_uri: String,
|
redirect_uri: String,
|
||||||
/// An opaque value used by the client to maintain state between the request and callback
|
/// An opaque value used by the client to maintain state between the request and callback
|
||||||
state: String,
|
state: String,
|
||||||
|
nonce: Option<String>
|
||||||
}
|
}
|
||||||
|
|
||||||
fn redirect_to_client(
|
fn redirect_to_client(
|
||||||
|
@ -34,7 +33,7 @@ fn redirect_to_client(
|
||||||
let target_url = format!("{}?code={}&state={}",
|
let target_url = format!("{}?code={}&state={}",
|
||||||
authorization_params.redirect_uri,
|
authorization_params.redirect_uri,
|
||||||
authorization_code,
|
authorization_code,
|
||||||
authorization_params.state,
|
authorization_params.state
|
||||||
);
|
);
|
||||||
debug!("Redirecting to {}", target_url);
|
debug!("Redirecting to {}", target_url);
|
||||||
|
|
||||||
|
@ -56,6 +55,7 @@ pub async fn authorize_form(
|
||||||
query_params: Query<AuthorizationParams>
|
query_params: Query<AuthorizationParams>
|
||||||
) -> impl IntoResponse {
|
) -> impl IntoResponse {
|
||||||
let Query(authorization_params) = query_params;
|
let Query(authorization_params) = query_params;
|
||||||
|
dbg!(&authorization_params);
|
||||||
|
|
||||||
// 1. Verify the app details
|
// 1. Verify the app details
|
||||||
let app = match app_state.config.applications
|
let app = match app_state.config.applications
|
||||||
|
@ -116,9 +116,10 @@ pub async fn authorize_form(
|
||||||
// Create new auth code
|
// Create new auth code
|
||||||
let authorization_code = get_random_alphanumerical(32);
|
let authorization_code = get_random_alphanumerical(32);
|
||||||
// Update last used timestamp for this authorization
|
// 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(existing_authorization.id)
|
||||||
.bind(authorization_code.clone())
|
.bind(authorization_code.clone())
|
||||||
|
.bind(authorization_params.nonce.clone())
|
||||||
.bind(Utc::now().to_rfc3339_opts(SecondsFormat::Millis, true))
|
.bind(Utc::now().to_rfc3339_opts(SecondsFormat::Millis, true))
|
||||||
.execute(&app_state.db.0)
|
.execute(&app_state.db.0)
|
||||||
.await.unwrap();
|
.await.unwrap();
|
||||||
|
@ -172,6 +173,7 @@ pub async fn perform_authorize(
|
||||||
Extension(token_claims): Extension<UserTokenClaims>,
|
Extension(token_claims): Extension<UserTokenClaims>,
|
||||||
Form(authorize_form): Form<AuthorizationParams>
|
Form(authorize_form): Form<AuthorizationParams>
|
||||||
) -> impl IntoResponse {
|
) -> impl IntoResponse {
|
||||||
|
dbg!(&authorize_form);
|
||||||
// 1. Get the app details
|
// 1. Get the app details
|
||||||
let app = match app_state.config.applications
|
let app = match app_state.config.applications
|
||||||
.iter()
|
.iter()
|
||||||
|
@ -203,6 +205,7 @@ pub async fn perform_authorize(
|
||||||
client_id: app.client_id.clone(),
|
client_id: app.client_id.clone(),
|
||||||
scopes: sqlx::types::Json(scopes),
|
scopes: sqlx::types::Json(scopes),
|
||||||
code: authorization_code.clone(),
|
code: authorization_code.clone(),
|
||||||
|
nonce: authorize_form.nonce.clone(),
|
||||||
last_used_at: Some(Utc::now()),
|
last_used_at: Some(Utc::now()),
|
||||||
created_at: Utc::now(),
|
created_at: Utc::now(),
|
||||||
};
|
};
|
||||||
|
@ -210,14 +213,15 @@ pub async fn perform_authorize(
|
||||||
// 3. Save authorization in DB with state
|
// 3. Save authorization in DB with state
|
||||||
let res = sqlx::query("
|
let res = sqlx::query("
|
||||||
INSERT INTO authorizations
|
INSERT INTO authorizations
|
||||||
(id, user_id, client_id, scopes, code, last_used_at, created_at)
|
(id, user_id, client_id, scopes, code, nonce, last_used_at, created_at)
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||||
")
|
")
|
||||||
.bind(authorization.id.clone())
|
.bind(authorization.id.clone())
|
||||||
.bind(authorization.user_id)
|
.bind(authorization.user_id)
|
||||||
.bind(authorization.client_id)
|
.bind(authorization.client_id)
|
||||||
.bind(authorization.scopes)
|
.bind(authorization.scopes)
|
||||||
.bind(authorization.code)
|
.bind(authorization.code)
|
||||||
|
.bind(authorization.nonce)
|
||||||
.bind(authorization.last_used_at.map(|x| x.to_rfc3339_opts(SecondsFormat::Millis, true)))
|
.bind(authorization.last_used_at.map(|x| x.to_rfc3339_opts(SecondsFormat::Millis, true)))
|
||||||
.bind(authorization.created_at.to_rfc3339_opts(SecondsFormat::Millis, true))
|
.bind(authorization.created_at.to_rfc3339_opts(SecondsFormat::Millis, true))
|
||||||
.execute(&app_state.db.0)
|
.execute(&app_state.db.0)
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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,9 +102,11 @@ pub async fn enforce_jwt_auth_middleware(
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
let token_claims: AppUserTokenClaims = match verify_token(&app_state.secrets, jwt) {
|
let token_claims: OAuth2AccessTokenClaims =
|
||||||
|
match verify_token(&app_state.config, &app_state.secrets, jwt) {
|
||||||
Ok(val) => val,
|
Ok(val) => val,
|
||||||
Err(_e) => {
|
Err(_e) => {
|
||||||
|
dbg!(_e);
|
||||||
return Err(
|
return Err(
|
||||||
(StatusCode::UNAUTHORIZED, Html("Unauthorized: The provided JWT is invalid."))
|
(StatusCode::UNAUTHORIZED, Html("Unauthorized: The provided JWT is invalid."))
|
||||||
);
|
);
|
||||||
|
|
|
@ -28,9 +28,11 @@ 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 =
|
||||||
|
match verify_token(&app_state.config, &app_state.secrets, jwt) {
|
||||||
Ok(val) => val,
|
Ok(val) => val,
|
||||||
Err(_e) => {
|
Err(_e) => {
|
||||||
|
dbg!(&_e);
|
||||||
// UserWebGUI: delete invalid JWT cookie
|
// UserWebGUI: delete invalid JWT cookie
|
||||||
return Err(
|
return Err(
|
||||||
(
|
(
|
||||||
|
|
|
@ -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::keys::get_signing_public_keys));
|
||||||
|
|
||||||
Router::new()
|
Router::new()
|
||||||
.merge(public_routes)
|
.merge(public_routes)
|
||||||
|
|
|
@ -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>> {
|
pub fn parse_scope(scope_str: &str) -> Result<Vec<AuthorizationScope>> {
|
||||||
let mut scopes: Vec<AuthorizationScope> = vec![];
|
let mut scopes: Vec<AuthorizationScope> = vec![];
|
||||||
for part in scope_str.split(' ') {
|
for part in scope_str.split(' ') {
|
||||||
scopes.push(
|
if part == "openid" {
|
||||||
AuthorizationScope::from_str(part).context("Cannot parse space-delimited scope.")?
|
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)
|
Ok(scopes)
|
||||||
}
|
}
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
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;
|
use kernel::models::{authorization::AuthorizationScope, config::Config, user::User};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use time::Duration;
|
use time::Duration;
|
||||||
|
|
||||||
|
@ -12,41 +12,91 @@ 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(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Access token for OAuth2 defined in RFC 9068
|
||||||
|
/// @See https://datatracker.ietf.org/doc/html/rfc9068
|
||||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||||
#[fully_pub]
|
#[fully_pub]
|
||||||
struct AppUserTokenClaims {
|
struct OAuth2AccessTokenClaims {
|
||||||
/// combined subject
|
/// Token issuer (URI to the issuer)
|
||||||
client_id: String,
|
iss: String,
|
||||||
user_id: String,
|
/// Audiance (In this case, the audiance is equal to the issuer)
|
||||||
scopes: Vec<AuthorizationScope>,
|
aud: String,
|
||||||
/// token expiration
|
/// End-user id assigned by the issuer (user_id)
|
||||||
|
sub: String,
|
||||||
|
/// Token expiration
|
||||||
exp: u64,
|
exp: u64,
|
||||||
/// token issuer
|
/// List of OAuth 2 scopes asked by the client
|
||||||
iss: String
|
scopes: Vec<AuthorizationScope>
|
||||||
}
|
}
|
||||||
|
|
||||||
impl AppUserTokenClaims {
|
impl OAuth2AccessTokenClaims {
|
||||||
pub fn new(client_id: &str, user_id: &str, scopes: Vec<AuthorizationScope>) -> Self {
|
pub fn new(config: &Config, user: &User, scopes: Vec<AuthorizationScope>) -> Self {
|
||||||
AppUserTokenClaims {
|
OAuth2AccessTokenClaims {
|
||||||
client_id: client_id.into(),
|
iss: config.instance.base_uri.clone(),
|
||||||
user_id: user_id.into(),
|
aud: config.instance.base_uri.clone(),
|
||||||
scopes,
|
sub: user.id.clone(),
|
||||||
exp: get_current_timestamp() + 86_000,
|
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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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";
|
||||||
|
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -25,6 +25,8 @@ struct Authorization {
|
||||||
client_id: String,
|
client_id: String,
|
||||||
scopes: Json<Vec<AuthorizationScope>>,
|
scopes: Json<Vec<AuthorizationScope>>,
|
||||||
|
|
||||||
|
nonce: Option<String>,
|
||||||
|
|
||||||
/// defined in https://datatracker.ietf.org/doc/html/rfc6749#section-4.1.2
|
/// defined in https://datatracker.ietf.org/doc/html/rfc6749#section-4.1.2
|
||||||
code: String,
|
code: String,
|
||||||
last_used_at: Option<DateTime<Utc>>,
|
last_used_at: Option<DateTime<Utc>>,
|
||||||
|
|
|
@ -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
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -14,7 +14,7 @@ enum UserStatus {
|
||||||
Active
|
Active
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(sqlx::FromRow, Deserialize, Serialize, Debug)]
|
#[derive(sqlx::FromRow, Deserialize, Serialize, Debug, Clone)]
|
||||||
#[fully_pub]
|
#[fully_pub]
|
||||||
struct User {
|
struct User {
|
||||||
/// uuid
|
/// uuid
|
||||||
|
|
|
@ -33,6 +33,7 @@ CREATE TABLE authorizations (
|
||||||
client_id TEXT NOT NULL,
|
client_id TEXT NOT NULL,
|
||||||
scopes TEXT, -- json array of app scope (permissions)
|
scopes TEXT, -- json array of app scope (permissions)
|
||||||
code TEXT,
|
code TEXT,
|
||||||
|
nonce TEXT, -- code used to associate client session to id_token
|
||||||
|
|
||||||
last_used_at DATETIME,
|
last_used_at DATETIME,
|
||||||
created_at DATETIME NOT NULL
|
created_at DATETIME NOT NULL
|
||||||
|
|
58
tests/hurl_integration/oidc_core/config.toml
Normal file
58
tests/hurl_integration/oidc_core/config.toml
Normal 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"
|
||||||
|
|
11
tests/hurl_integration/oidc_core/init_db.sh
Executable file
11
tests/hurl_integration/oidc_core/init_db.sh
Executable 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
|
41
tests/hurl_integration/oidc_core/main.hurl
Normal file
41
tests/hurl_integration/oidc_core/main.hurl
Normal 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
|
|
@ -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"
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -1,3 +1,4 @@
|
||||||
|
signing_key = "tmp/secrets/signing.key"
|
||||||
applications = []
|
applications = []
|
||||||
|
|
||||||
roles = []
|
roles = []
|
||||||
|
|
Loading…
Reference in a new issue