WIP
This commit is contained in:
commit
663af59488
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
/target
|
||||||
|
/.vscode
|
||||||
|
/config.toml
|
3080
Cargo.lock
generated
Normal file
3080
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load diff
23
Cargo.toml
Normal file
23
Cargo.toml
Normal file
|
@ -0,0 +1,23 @@
|
||||||
|
[package]
|
||||||
|
name = "subscribe-list"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
|
||||||
|
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
actix-session = { version = "0.9.0", features = ["cookie-session"] }
|
||||||
|
actix-web = { version = "4.5.1", features = ["secure-cookies"] }
|
||||||
|
clap = { version = "4.5.4", features = ["derive"] }
|
||||||
|
clio = { version = "0.3.5", features = ["clap-parse"] }
|
||||||
|
color-eyre = "0.6.3"
|
||||||
|
eyre = "0.6.12"
|
||||||
|
futures = "0.3.30"
|
||||||
|
indexmap = { version = "2.2.6", features = ["serde"] }
|
||||||
|
listenfd = "1.0.1"
|
||||||
|
openidconnect = "3.5.0"
|
||||||
|
serde = { version = "1.0.197", features = ["derive"] }
|
||||||
|
serde_json = "1.0.115"
|
||||||
|
tinytemplate = "1.2.1"
|
||||||
|
tokio = { version = "1.37.0", features = ["macros", "rt-multi-thread"] }
|
||||||
|
toml = { version = "0.8.12", features = ["preserve_order"] }
|
13
config.toml.sample
Normal file
13
config.toml.sample
Normal file
|
@ -0,0 +1,13 @@
|
||||||
|
[oidc.google]
|
||||||
|
pretty_name = "Google"
|
||||||
|
issuer_url = "https://accounts.google.com"
|
||||||
|
client_id = "<TODO>"
|
||||||
|
secret = "<TODO>"
|
||||||
|
scopes = ["email"]
|
||||||
|
|
||||||
|
[oidc.debian-salsa]
|
||||||
|
pretty_name = "Debian Salsa"
|
||||||
|
issuer_url = "https://salsa.debian.org"
|
||||||
|
client_id = "<TODO>"
|
||||||
|
secret = "<TODO>"
|
||||||
|
scopes = ["email"]
|
193
src/app.rs
Normal file
193
src/app.rs
Normal file
|
@ -0,0 +1,193 @@
|
||||||
|
use crate::config::{Config, OIDCProvider, OIDCProviderState};
|
||||||
|
use actix_session::Session;
|
||||||
|
use actix_web::{
|
||||||
|
cookie::Cookie,
|
||||||
|
get,
|
||||||
|
http::{
|
||||||
|
header::{ContentType, LOCATION},
|
||||||
|
StatusCode, Uri,
|
||||||
|
},
|
||||||
|
web::{self, ServiceConfig},
|
||||||
|
HttpResponse, HttpResponseBuilder, Responder,
|
||||||
|
};
|
||||||
|
use openidconnect::{
|
||||||
|
core::CoreAuthenticationFlow, http::HeaderValue, CsrfToken, EndUserEmail, Nonce,
|
||||||
|
};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::{fmt::Write, marker::PhantomData};
|
||||||
|
use tinytemplate::TinyTemplate;
|
||||||
|
|
||||||
|
pub trait DynTemplate {
|
||||||
|
fn name(&self) -> &'static str;
|
||||||
|
fn text(&self) -> &'static str;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub trait Template: Serialize
|
||||||
|
where
|
||||||
|
PhantomData<Self>: DynTemplate,
|
||||||
|
{
|
||||||
|
const NAME: &'static str;
|
||||||
|
const TEXT: &'static str;
|
||||||
|
fn render(&self) -> tinytemplate::error::Result<String> {
|
||||||
|
with_templates(|templates| templates.render(Self::NAME, &self))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<T: Template> DynTemplate for PhantomData<T> {
|
||||||
|
fn name(&self) -> &'static str {
|
||||||
|
T::NAME
|
||||||
|
}
|
||||||
|
|
||||||
|
fn text(&self) -> &'static str {
|
||||||
|
T::TEXT
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
macro_rules! templates {
|
||||||
|
(
|
||||||
|
$(
|
||||||
|
#[text = $text:literal]
|
||||||
|
$(#[$meta:meta])*
|
||||||
|
$vis:vis struct $name:ident $fields:tt
|
||||||
|
)*
|
||||||
|
) => {
|
||||||
|
$(
|
||||||
|
$(#[$meta])*
|
||||||
|
$vis struct $name $fields
|
||||||
|
|
||||||
|
impl Template for $name {
|
||||||
|
const NAME: &'static str = stringify!($name);
|
||||||
|
const TEXT: &'static str = $text;
|
||||||
|
}
|
||||||
|
)*
|
||||||
|
const TEMPLATES: &'static [&'static dyn DynTemplate] = &[$(&PhantomData::<$name>,)*];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct SubscriptionLoggedOutTemplateProviders {
|
||||||
|
pub pretty_name: String,
|
||||||
|
pub url: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
templates! {
|
||||||
|
#[text = r#"<head><title>Subscription</title></head>
|
||||||
|
<body>
|
||||||
|
<ul>
|
||||||
|
{{- for provider in providers -}}
|
||||||
|
<li><a href="{provider.url}">{provider.pretty_name}</a></li>
|
||||||
|
{{- endfor -}}
|
||||||
|
</ul>
|
||||||
|
</body>"#]
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct SubscriptionLoggedOutTemplate {
|
||||||
|
pub providers: Vec<SubscriptionLoggedOutTemplateProviders>,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn make_templates() -> TinyTemplate<'static> {
|
||||||
|
let mut templates = TinyTemplate::new();
|
||||||
|
for template in TEMPLATES {
|
||||||
|
if let Err(e) = templates.add_template(template.name(), template.text()) {
|
||||||
|
panic!("error parsing template {}: {}", template.name(), e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
templates
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn with_templates<F: FnOnce(&TinyTemplate<'static>) -> R, R>(f: F) -> R {
|
||||||
|
thread_local! {
|
||||||
|
static TEMPLATES: TinyTemplate<'static> = make_templates();
|
||||||
|
}
|
||||||
|
TEMPLATES.with(f)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||||
|
pub struct SessionState {
|
||||||
|
pub email: EndUserEmail,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SessionState {
|
||||||
|
pub fn get(session: &Session) -> Option<Self> {
|
||||||
|
session
|
||||||
|
.get("state")
|
||||||
|
.expect("state is signed so we know it's valid")
|
||||||
|
}
|
||||||
|
pub fn set(&self, session: &Session) {
|
||||||
|
session
|
||||||
|
.insert("state", self)
|
||||||
|
.expect("serialization can't fail");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl OIDCProvider {}
|
||||||
|
|
||||||
|
const NONCE: &'static str = "nonce";
|
||||||
|
const CSRF_TOKEN: &'static str = "csrf_token";
|
||||||
|
|
||||||
|
#[get("/subscription/login/{provider}")]
|
||||||
|
pub async fn login(
|
||||||
|
config: web::Data<Config>,
|
||||||
|
session: Session,
|
||||||
|
path: web::Path<(String,)>,
|
||||||
|
uri: Uri,
|
||||||
|
) -> impl Responder {
|
||||||
|
let mut resp = HttpResponse::SeeOther().body("");
|
||||||
|
resp.headers_mut()
|
||||||
|
.insert(LOCATION, "/subscription".parse().unwrap());
|
||||||
|
if SessionState::get(&session).is_some() {
|
||||||
|
return resp;
|
||||||
|
}
|
||||||
|
let Some(provider) = config.oidc.get(&path.0) else {
|
||||||
|
return resp;
|
||||||
|
};
|
||||||
|
let (url, csrf_token, nonce) = provider
|
||||||
|
.state()
|
||||||
|
.client
|
||||||
|
.authorize_url(
|
||||||
|
CoreAuthenticationFlow::AuthorizationCode,
|
||||||
|
CsrfToken::new_random,
|
||||||
|
Nonce::new_random,
|
||||||
|
)
|
||||||
|
.add_scopes(provider.scopes.clone())
|
||||||
|
.url();
|
||||||
|
session.insert(CSRF_TOKEN, csrf_token).unwrap();
|
||||||
|
session.insert(NONCE, nonce).unwrap();
|
||||||
|
resp.headers_mut()
|
||||||
|
.insert(LOCATION, url.as_str().parse().unwrap());
|
||||||
|
resp
|
||||||
|
}
|
||||||
|
|
||||||
|
#[get("/subscription")]
|
||||||
|
pub async fn subscription(config: web::Data<Config>, session: Session) -> impl Responder {
|
||||||
|
if let Some(SessionState { email }) = SessionState::get(&session) {
|
||||||
|
todo!()
|
||||||
|
} else {
|
||||||
|
let mut template = SubscriptionLoggedOutTemplate { providers: vec![] };
|
||||||
|
for (name, provider) in config.oidc.iter() {
|
||||||
|
template
|
||||||
|
.providers
|
||||||
|
.push(SubscriptionLoggedOutTemplateProviders {
|
||||||
|
pretty_name: provider.pretty_name.clone(),
|
||||||
|
url: format!("/subscription/login/{name}"),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
HttpResponse::Ok()
|
||||||
|
.content_type(ContentType::html())
|
||||||
|
.body(template.render().expect("rendering can't fail"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn all_services(cfg: &mut ServiceConfig) {
|
||||||
|
cfg.service(subscription).service(login);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_make_templates() {
|
||||||
|
make_templates();
|
||||||
|
}
|
||||||
|
}
|
72
src/cli.rs
Normal file
72
src/cli.rs
Normal file
|
@ -0,0 +1,72 @@
|
||||||
|
use crate::config;
|
||||||
|
use clap::{ArgAction, Args, Parser};
|
||||||
|
use eyre::ensure;
|
||||||
|
use futures::try_join;
|
||||||
|
use listenfd::ListenFd;
|
||||||
|
use std::{net::TcpListener, os::unix::net::UnixListener, path::PathBuf};
|
||||||
|
|
||||||
|
#[derive(Parser, Debug)]
|
||||||
|
pub struct CLI {
|
||||||
|
#[arg(long, value_parser, value_name = "path/to/config.toml")]
|
||||||
|
pub config: config::Config,
|
||||||
|
#[command(flatten)]
|
||||||
|
pub listen: Listen,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CLI {
|
||||||
|
pub async fn resolve(&mut self, listenfd: &mut ListenFd) -> eyre::Result<()> {
|
||||||
|
try_join!(self.config.resolve(), self.listen.resolve(listenfd))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Args, Debug)]
|
||||||
|
#[group(required = true)]
|
||||||
|
pub struct Listen {
|
||||||
|
#[arg(long, value_name = "HOST:PORT")]
|
||||||
|
pub listen_tcp: Vec<String>,
|
||||||
|
#[arg(long, value_name = "path/to/socket.sock")]
|
||||||
|
pub listen_unix: Vec<PathBuf>,
|
||||||
|
#[arg(long, action = ArgAction::SetTrue, default_missing_value = "", value_parser = |_: &str| Ok::<_, String>(ListenFdSockets(vec![])))]
|
||||||
|
pub listen_fd: Option<ListenFdSockets>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct ListenFdSockets(pub Vec<ListenFdSocket>);
|
||||||
|
|
||||||
|
impl Clone for ListenFdSockets {
|
||||||
|
fn clone(&self) -> Self {
|
||||||
|
panic!("not clonable")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub enum ListenFdSocket {
|
||||||
|
Tcp(TcpListener),
|
||||||
|
Unix(UnixListener),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Listen {
|
||||||
|
pub async fn resolve(&mut self, listenfd: &mut ListenFd) -> eyre::Result<()> {
|
||||||
|
if let Some(listen_fd_sockets) = &mut self.listen_fd {
|
||||||
|
ensure!(
|
||||||
|
listenfd.len() != 0,
|
||||||
|
"no file descriptor passed in through LISTEN_FDS systemd protocol"
|
||||||
|
);
|
||||||
|
for index in 0..listenfd.len() {
|
||||||
|
let mut result = listenfd
|
||||||
|
.take_tcp_listener(index)
|
||||||
|
.transpose()
|
||||||
|
.expect("fds not yet taken")
|
||||||
|
.map(ListenFdSocket::Tcp);
|
||||||
|
if result.is_err() {
|
||||||
|
if let Ok(Some(v)) = listenfd.take_unix_listener(index) {
|
||||||
|
result = Ok(ListenFdSocket::Unix(v));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
listen_fd_sockets.0.push(result?);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
96
src/config.rs
Normal file
96
src/config.rs
Normal file
|
@ -0,0 +1,96 @@
|
||||||
|
use clap::builder::{TryMapValueParser, TypedValueParser, ValueParserFactory};
|
||||||
|
use clio::CachedInput;
|
||||||
|
use eyre::Context;
|
||||||
|
use futures::future::try_join_all;
|
||||||
|
use indexmap::IndexMap;
|
||||||
|
use openidconnect::{
|
||||||
|
core::{CoreClient, CoreProviderMetadata},
|
||||||
|
ClientId, ClientSecret, IssuerUrl, Scope,
|
||||||
|
};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
#[derive(Deserialize, Serialize, Debug, Clone)]
|
||||||
|
pub struct Config {
|
||||||
|
pub oidc: IndexMap<String, OIDCProvider>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ValueParserFactory for Config {
|
||||||
|
type Parser = TryMapValueParser<
|
||||||
|
<CachedInput as ValueParserFactory>::Parser,
|
||||||
|
fn(CachedInput) -> eyre::Result<Config>,
|
||||||
|
>;
|
||||||
|
|
||||||
|
fn value_parser() -> Self::Parser {
|
||||||
|
CachedInput::value_parser().try_map(Config::load)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Config {
|
||||||
|
pub const EXAMPLE: &'static str =
|
||||||
|
include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/config.toml.sample"));
|
||||||
|
pub fn load_str(input: &str, path: impl ToString) -> eyre::Result<Config> {
|
||||||
|
toml::from_str(input).wrap_err_with(|| path.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn load(input: CachedInput) -> eyre::Result<Config> {
|
||||||
|
let s = std::str::from_utf8(input.get_data()).wrap_err_with(|| input.path().to_string())?;
|
||||||
|
toml::from_str(s).wrap_err_with(|| input.path().to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn resolve(&mut self) -> eyre::Result<()> {
|
||||||
|
try_join_all(self.oidc.iter_mut().map(|(_, provider)| provider.resolve())).await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct OIDCProviderState {
|
||||||
|
pub client: CoreClient,
|
||||||
|
pub provider_metadata: CoreProviderMetadata,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize, Serialize, Debug, Clone)]
|
||||||
|
pub struct OIDCProvider {
|
||||||
|
pub pretty_name: String,
|
||||||
|
pub issuer_url: IssuerUrl,
|
||||||
|
pub client_id: ClientId,
|
||||||
|
pub secret: ClientSecret,
|
||||||
|
pub scopes: Vec<Scope>,
|
||||||
|
#[serde(skip)]
|
||||||
|
state: Option<OIDCProviderState>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl OIDCProvider {
|
||||||
|
pub fn state(&self) -> &OIDCProviderState {
|
||||||
|
self.state.as_ref().expect("resolve called by main")
|
||||||
|
}
|
||||||
|
pub async fn resolve(&mut self) -> eyre::Result<()> {
|
||||||
|
let provider_metadata = CoreProviderMetadata::discover_async(
|
||||||
|
self.issuer_url.clone(),
|
||||||
|
openidconnect::reqwest::async_http_client,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
let client = CoreClient::from_provider_metadata(
|
||||||
|
provider_metadata.clone(),
|
||||||
|
self.client_id.clone(),
|
||||||
|
Some(self.secret.clone()),
|
||||||
|
);
|
||||||
|
let client = client.disable_openid_scope();
|
||||||
|
self.state = Some(OIDCProviderState {
|
||||||
|
client,
|
||||||
|
provider_metadata,
|
||||||
|
});
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_example() -> eyre::Result<()> {
|
||||||
|
Config::load_str(Config::EXAMPLE, "config.toml.sample")?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
54
src/main.rs
Normal file
54
src/main.rs
Normal file
|
@ -0,0 +1,54 @@
|
||||||
|
use crate::cli::{Listen, ListenFdSocket, ListenFdSockets};
|
||||||
|
use actix_session::{
|
||||||
|
config::{BrowserSession, SessionMiddlewareBuilder},
|
||||||
|
storage::CookieSessionStore,
|
||||||
|
SessionMiddleware,
|
||||||
|
};
|
||||||
|
use actix_web::{cookie::Key, web, App, HttpServer};
|
||||||
|
use clap::Parser;
|
||||||
|
use listenfd::ListenFd;
|
||||||
|
|
||||||
|
mod app;
|
||||||
|
mod cli;
|
||||||
|
mod config;
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() -> eyre::Result<()> {
|
||||||
|
let mut listenfd = ListenFd::from_env();
|
||||||
|
color_eyre::install()?;
|
||||||
|
let mut args = cli::CLI::parse();
|
||||||
|
args.resolve(&mut listenfd).await?;
|
||||||
|
let config = web::Data::new(args.config);
|
||||||
|
let cookie_session_key = Key::generate();
|
||||||
|
let mut server = HttpServer::new(move || {
|
||||||
|
let cookie_session =
|
||||||
|
SessionMiddleware::builder(CookieSessionStore::default(), cookie_session_key.clone())
|
||||||
|
.cookie_name("session".into())
|
||||||
|
.build();
|
||||||
|
App::new()
|
||||||
|
.wrap(cookie_session)
|
||||||
|
.app_data(config.clone())
|
||||||
|
.configure(app::all_services)
|
||||||
|
});
|
||||||
|
let Listen {
|
||||||
|
listen_tcp,
|
||||||
|
listen_unix,
|
||||||
|
listen_fd,
|
||||||
|
} = &mut args.listen;
|
||||||
|
for sock in listen_tcp.iter() {
|
||||||
|
server = server.bind_auto_h2c(sock)?;
|
||||||
|
}
|
||||||
|
for sock in listen_unix.iter() {
|
||||||
|
server = server.bind_uds(sock)?;
|
||||||
|
}
|
||||||
|
if let Some(ListenFdSockets(sockets)) = listen_fd {
|
||||||
|
for sock in sockets.drain(..) {
|
||||||
|
server = match sock {
|
||||||
|
ListenFdSocket::Tcp(sock) => server.listen_auto_h2c(sock)?,
|
||||||
|
ListenFdSocket::Unix(sock) => server.listen_uds(sock)?,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
server.run().await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
Loading…
Reference in a new issue