This commit is contained in:
Jacob Lifshay 2024-04-08 01:50:25 -07:00
commit 663af59488
Signed by: programmerjake
SSH key fingerprint: SHA256:B1iRVvUJkvd7upMIiMqn6OyxvD2SgJkAH3ZnUOj6z+c
8 changed files with 3534 additions and 0 deletions

3
.gitignore vendored Normal file
View file

@ -0,0 +1,3 @@
/target
/.vscode
/config.toml

3080
Cargo.lock generated Normal file

File diff suppressed because it is too large Load diff

23
Cargo.toml Normal file
View 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
View 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
View 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
View 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
View 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
View 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(())
}