Initial commit

This commit is contained in:
Daan Boerlage 2023-11-18 18:00:54 +01:00
commit 5b0f728fce
Signed by: daan
GPG key ID: FCE070E1E4956606
17 changed files with 1971 additions and 0 deletions

1
.gitignore vendored Normal file
View file

@ -0,0 +1 @@
/target

1504
Cargo.lock generated Normal file

File diff suppressed because it is too large Load diff

36
Cargo.toml Normal file
View file

@ -0,0 +1,36 @@
[package]
name = "sleutel"
version = "0.1.0"
edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
# Runtime
tokio = { version = "1", features = ["full"] }
# Web
axum = { version = "0.6", features = ["headers", "tracing"] }
hyper = { version = "0.14", features = ["full"] }
# Tracing
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter", "json"]}
# Data
serde = { version = "1", features = ["derive"]}
serde_json = "1"
# Error Handling
eyre = "0.6"
thiserror = "1.0"
snafu = "0.7"
# JWx
jsonwebtoken = "9"
# Misc
chrono = { version = "0.4", default-features = false, features = ["clock"] }
ulid = { version = "1.1", features = ["serde"] }

17
readme.md Normal file
View file

@ -0,0 +1,17 @@
# Sleutel
The key to ones' online identity.
Sleutel is an identity directory aims to be the one-stop shop of all authentication and identity related matters.
## Protocols
* [x] WebFinger
* [ ] OpenID Connect
* [ ] SAML 1.x
* [ ] SAML 2
* [ ] LDAP
* [ ] Kerberos
* [ ] RADIUS
* [ ] [IndieAuth](https://indieweb.org/IndieAuth)
* [ ] Web Key Directory

27
src/api.rs Normal file
View file

@ -0,0 +1,27 @@
mod base;
mod identities;
mod middleware;
mod openid;
mod saml;
use axum::Router;
use std::net::SocketAddr;
pub async fn start(socket: SocketAddr) -> eyre::Result<()> {
let app = Router::new()
.merge(openid::routes())
.merge(saml::routes())
.merge(identities::routes())
.layer(axum::middleware::from_fn(
middleware::request_logger::print_request_response,
))
.fallback(base::not_found);
tracing::info!("Listing on: {:?}", socket);
let _ = axum::Server::bind(&socket)
.serve(app.into_make_service())
.with_graceful_shutdown(crate::lifecycle::signals::shutdown())
.await;
Ok(())
}

10
src/api/base.rs Normal file
View file

@ -0,0 +1,10 @@
use axum::http::StatusCode;
use axum::response::IntoResponse;
pub async fn not_found() -> impl IntoResponse {
(StatusCode::OK, "Not Found")
}
pub async fn stub() -> impl IntoResponse {
(StatusCode::NOT_IMPLEMENTED, "Not yet implemented")
}

8
src/api/identities.rs Normal file
View file

@ -0,0 +1,8 @@
use crate::api::base::stub;
use axum::routing::{any, Router};
pub fn routes() -> Router {
Router::new()
.route("/identities", any(stub))
.route("/identities/:identity", any(stub))
}

1
src/api/middleware.rs Normal file
View file

@ -0,0 +1 @@
pub mod request_logger;

View file

@ -0,0 +1,68 @@
use axum::body::Body;
use axum::extract::MatchedPath;
use axum::http::{Request, StatusCode};
use axum::middleware::Next;
use axum::response::IntoResponse;
pub async fn print_request_response(
request: Request<Body>,
next: Next<Body>,
) -> Result<impl IntoResponse, (StatusCode, String)> {
let matched_path = request
.extensions()
.get::<MatchedPath>()
.map(MatchedPath::as_str);
let span = tracing::info_span!(
"http_request",
method = ?request.method(),
matched_path,
);
let span_ptr = span.enter();
let (parts, body) = request.into_parts();
let bytes = match hyper::body::to_bytes(body).await {
Ok(bytes) => bytes,
Err(err) => {
return Err((
StatusCode::BAD_REQUEST,
format!("failed to read body: {}", err),
));
}
};
tracing::info!(
"Incoming request on '{} {}' with user agent '{}' and body: '{}'",
parts.method,
parts.uri,
match parts.headers.get("user-agent") {
Some(x) => x.to_str().unwrap_or("(failed to parse)"),
None => "(empty)",
},
match std::str::from_utf8(&bytes) {
Ok(body) => body,
Err(e) => {
tracing::warn!("Failed to transform request body to utf8: {:?}", e);
""
}
}
);
let res = next
.run(Request::from_parts(parts, Body::from(bytes)))
.await;
let status = res.status();
match status {
StatusCode::OK | StatusCode::NOT_FOUND => {
tracing::info!("request finished with status: {}", status)
}
_ => tracing::warn!("request finished with status: {}", status),
}
drop(span_ptr);
Ok(res)
}

15
src/api/openid.rs Normal file
View file

@ -0,0 +1,15 @@
pub mod discovery;
pub mod webfinger;
use crate::api::base::stub;
use axum::routing::{any, get, Router};
pub fn routes() -> Router {
Router::new()
.route("/.well-known/openid-configuration", get(discovery::get))
.route("/.well-known/webfinger", get(webfinger::finger))
.route("/connect/authorize", any(stub))
.route("/connect/jwks", any(stub))
.route("/connect/token", any(stub))
.route("/connect/userinfo", any(stub))
}

View file

@ -0,0 +1,88 @@
use axum::extract::Host;
use axum::http::StatusCode;
use axum::response::IntoResponse;
use axum::Json;
use serde::{Deserialize, Serialize};
use std::fmt::{Display, Formatter};
#[derive(Serialize, Deserialize)]
struct DiscoveryDocument {
pub authorization_endpoint: String,
pub claims_supported: Vec<String>,
pub code_challenge_methods_supported: Vec<String>,
pub grant_types_supported: Vec<String>,
pub id_token_signing_alg_values_supported: Vec<String>,
pub issuer: String,
pub jwks_uri: String,
pub response_types_supported: Vec<String>,
pub scopes_supported: Vec<String>,
pub subject_types_supported: Vec<String>,
pub token_endpoint: String,
pub token_endpoint_auth_methods_supported: Vec<String>,
pub userinfo_endpoint: String,
}
enum IssuerSegments {
Authorize,
Jwks,
Token,
Top,
UserInfo,
}
impl Display for IssuerSegments {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
match self {
IssuerSegments::Authorize => write!(f, "authorize"),
IssuerSegments::Jwks => write!(f, "jwks"),
IssuerSegments::Token => write!(f, "token"),
IssuerSegments::Top => write!(f, ""),
IssuerSegments::UserInfo => write!(f, "userinfo"),
}
}
}
fn format_issuer(host: &str, segment: IssuerSegments) -> String {
match segment {
IssuerSegments::Top => format!("https://{}/", host),
_ => format!("https://{}/connect/{}", host, segment),
}
}
pub async fn get(Host(host): Host) -> impl IntoResponse {
tracing::info!("{}", host);
let doc = DiscoveryDocument {
authorization_endpoint: format_issuer(&host, IssuerSegments::Authorize),
claims_supported: vec![
"aud".to_string(),
"email".to_string(),
"exp".to_string(),
"groups".to_string(),
"iat".to_string(),
"iss".to_string(),
"sub".to_string(),
"username".to_string(),
],
code_challenge_methods_supported: vec!["S256".to_string(), "plain".to_string()],
grant_types_supported: vec!["authorization_code".to_string(), "implicit".to_string()],
id_token_signing_alg_values_supported: vec!["RS256".to_string()],
issuer: format_issuer(&host, IssuerSegments::Top),
jwks_uri: format_issuer(&host, IssuerSegments::Jwks),
response_types_supported: vec![
"code".to_string(),
"code id_token".to_string(),
"id_token".to_string(),
"id_token token".to_string(),
],
scopes_supported: vec!["email".to_string(), "openid".to_string()],
subject_types_supported: vec!["public".to_string()],
token_endpoint: format_issuer(&host, IssuerSegments::Token),
token_endpoint_auth_methods_supported: vec![
"client_secret_basic".to_string(),
"client_secret_post".to_string(),
],
userinfo_endpoint: format_issuer(&host, IssuerSegments::UserInfo),
};
(StatusCode::NOT_FOUND, Json(doc))
}

130
src/api/openid/webfinger.rs Normal file
View file

@ -0,0 +1,130 @@
use axum::extract::{Host, Query};
use axum::http::StatusCode;
use axum::response::IntoResponse;
use serde::{Deserialize, Serialize};
use std::fmt::{Display, Formatter};
#[derive(Serialize)]
struct ResourceDescriptor {
pub subject: String,
pub links: Vec<Link>,
}
#[derive(Serialize)]
struct Link {
pub rel: String,
pub href: String,
}
#[derive(Clone)]
enum DescriptorTypes {
Issuer(String),
}
#[derive(Debug, PartialEq)]
pub enum Rels {
Connect1Issuer,
Unsupported,
}
impl From<String> for Rels {
fn from(value: String) -> Self {
match value.as_str() {
"http://openid.net/specs/connect/1.0/issuer" => Self::Connect1Issuer,
_ => Self::Unsupported,
}
}
}
impl Display for Rels {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
match self {
Rels::Connect1Issuer => write!(f, "http://openid.net/specs/connect/1.0/issuer"),
Rels::Unsupported => write!(f, ""),
}
}
}
impl From<DescriptorTypes> for Rels {
fn from(value: DescriptorTypes) -> Self {
match value {
DescriptorTypes::Issuer(_) => Self::Connect1Issuer,
}
}
}
impl From<DescriptorTypes> for Link {
fn from(value: DescriptorTypes) -> Self {
match value.clone() {
DescriptorTypes::Issuer(host) => Self {
rel: Rels::from(value).to_string(),
href: format!("https://{}", host),
},
}
}
}
#[derive(Debug, PartialEq)]
enum Resource {
Acct(String),
Unsupported,
}
struct ResourceString(String);
impl From<ResourceString> for Resource {
fn from(ResourceString(value): ResourceString) -> Self {
let (t, v) = match value.split_once(':') {
Some(x) => x,
None => return Self::Unsupported,
};
match t {
"acct" => Self::Acct(v.to_string()),
_ => Self::Unsupported,
}
}
}
#[derive(Debug, Deserialize)]
pub struct FingerQuery {
resource: Option<String>,
rel: Option<String>,
}
pub async fn finger(Query(q): Query<FingerQuery>, Host(host): Host) -> impl IntoResponse {
let rel = match q.rel {
Some(x) => Rels::from(x),
None => Rels::Unsupported,
};
let resource = match q.resource {
Some(x) => Resource::from(ResourceString(x)),
None => Resource::Unsupported,
};
tracing::warn!("{} - {:?}", rel, resource);
match resource {
Resource::Acct(subject) => {
let doc = ResourceDescriptor {
subject,
links: vec![match rel {
Rels::Connect1Issuer => DescriptorTypes::Issuer(host).into(),
Rels::Unsupported => {
return (
StatusCode::NOT_IMPLEMENTED,
"Rel type not supported".to_string(),
)
}
}],
};
(StatusCode::OK, serde_json::to_string(&doc).unwrap())
}
Resource::Unsupported => (
StatusCode::NOT_IMPLEMENTED,
"Resource type not supported".to_string(),
),
}
}

8
src/api/saml.rs Normal file
View file

@ -0,0 +1,8 @@
use crate::api::base::stub;
use axum::routing::{any, get, Router};
pub fn routes() -> Router {
Router::new()
.route("/.well-known/saml-metadata.xml", get(stub))
.route("/saml", any(stub))
}

2
src/lifecycle.rs Normal file
View file

@ -0,0 +1,2 @@
pub mod logging;
pub mod signals;

19
src/lifecycle/logging.rs Normal file
View file

@ -0,0 +1,19 @@
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt, EnvFilter};
#[cfg(debug_assertions)]
pub fn init() {
let default_filter = "DEBUG,rustls=INFO,hyper=INFO,axum::rejection=TRACE,h2=INFO";
tracing_subscriber::registry()
.with(EnvFilter::try_from_default_env().unwrap_or(default_filter.into()))
.with(tracing_subscriber::fmt::layer().pretty())
.init();
}
#[cfg(not(debug_assertions))]
pub fn init() {
let default_filter = "INFO,axum::rejection=TRACE,reqwest::connect=DEBUG,aws_config=WARN";
tracing_subscriber::registry()
.with(EnvFilter::try_from_default_env().unwrap_or(default_filter.into()))
.with(tracing_subscriber::fmt::layer().json())
.init();
}

25
src/lifecycle/signals.rs Normal file
View file

@ -0,0 +1,25 @@
pub async fn shutdown() {
let ctrl_c = async {
tokio::signal::ctrl_c()
.await
.expect("failed to install Ctrl+C handler");
};
#[cfg(unix)]
let terminate = async {
tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())
.expect("failed to install signal handler")
.recv()
.await;
};
#[cfg(not(unix))]
let terminate = std::future::pending::<()>();
tokio::select! {
_ = ctrl_c => {},
_ = terminate => {},
}
tracing::warn!("signal received, starting graceful shutdown");
}

12
src/main.rs Normal file
View file

@ -0,0 +1,12 @@
mod api;
mod lifecycle;
use std::net::SocketAddr;
#[tokio::main]
async fn main() -> eyre::Result<()> {
lifecycle::logging::init();
let api_socket = SocketAddr::from(([0, 0, 0, 0], 3030));
api::start(api_socket).await
}