Initial commit
This commit is contained in:
commit
5b0f728fce
17 changed files with 1971 additions and 0 deletions
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
|
@ -0,0 +1 @@
|
||||||
|
/target
|
1504
Cargo.lock
generated
Normal file
1504
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load diff
36
Cargo.toml
Normal file
36
Cargo.toml
Normal 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
17
readme.md
Normal 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
27
src/api.rs
Normal 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
10
src/api/base.rs
Normal 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
8
src/api/identities.rs
Normal 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
1
src/api/middleware.rs
Normal file
|
@ -0,0 +1 @@
|
||||||
|
pub mod request_logger;
|
68
src/api/middleware/request_logger.rs
Normal file
68
src/api/middleware/request_logger.rs
Normal 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
15
src/api/openid.rs
Normal 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))
|
||||||
|
}
|
88
src/api/openid/discovery.rs
Normal file
88
src/api/openid/discovery.rs
Normal 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
130
src/api/openid/webfinger.rs
Normal 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
8
src/api/saml.rs
Normal 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
2
src/lifecycle.rs
Normal file
|
@ -0,0 +1,2 @@
|
||||||
|
pub mod logging;
|
||||||
|
pub mod signals;
|
19
src/lifecycle/logging.rs
Normal file
19
src/lifecycle/logging.rs
Normal 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
25
src/lifecycle/signals.rs
Normal 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
12
src/main.rs
Normal 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
|
||||||
|
}
|
Loading…
Reference in a new issue