first implementation

This commit is contained in:
Paul Zinselmeyer 2023-10-22 00:29:02 +02:00
parent 1c1701040a
commit 7de7f5ede1
5 changed files with 347 additions and 59 deletions

70
Cargo.lock generated
View file

@ -311,12 +311,17 @@ dependencies = [
name = "binctl" name = "binctl"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"axum",
"clap", "clap",
"confy", "confy",
"dirs",
"open",
"openidconnect", "openidconnect",
"reqwest", "reqwest",
"serde", "serde",
"thiserror", "thiserror",
"tokio",
"tokio-util",
] ]
[[package]] [[package]]
@ -647,7 +652,16 @@ version = "4.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f51c5d4ddabd36886dd3e1438cb358cdcb0d7c499cb99cb4ac2e38e18b5cb210" checksum = "f51c5d4ddabd36886dd3e1438cb358cdcb0d7c499cb99cb4ac2e38e18b5cb210"
dependencies = [ dependencies = [
"dirs-sys", "dirs-sys 0.3.7",
]
[[package]]
name = "dirs"
version = "5.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "44c45a9d03d6676652bcb5e724c7e988de1acad23a711b5217ab9cbecbec2225"
dependencies = [
"dirs-sys 0.4.1",
] ]
[[package]] [[package]]
@ -661,6 +675,18 @@ dependencies = [
"winapi", "winapi",
] ]
[[package]]
name = "dirs-sys"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c"
dependencies = [
"libc",
"option-ext",
"redox_users",
"windows-sys",
]
[[package]] [[package]]
name = "dotenvy" name = "dotenvy"
version = "0.15.7" version = "0.15.7"
@ -1163,6 +1189,15 @@ version = "2.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "28b29a3cd74f0f4598934efe3aeba42bae0eb4680554128851ebbecb02af14e6" checksum = "28b29a3cd74f0f4598934efe3aeba42bae0eb4680554128851ebbecb02af14e6"
[[package]]
name = "is-docker"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "928bae27f42bc99b60d9ac7334e3a21d10ad8f1835a4e12ec3ec0464765ed1b3"
dependencies = [
"once_cell",
]
[[package]] [[package]]
name = "is-terminal" name = "is-terminal"
version = "0.4.9" version = "0.4.9"
@ -1174,6 +1209,16 @@ dependencies = [
"windows-sys", "windows-sys",
] ]
[[package]]
name = "is-wsl"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "173609498df190136aa7dea1a91db051746d339e18476eed5ca40521f02d7aa5"
dependencies = [
"is-docker",
"once_cell",
]
[[package]] [[package]]
name = "itertools" name = "itertools"
version = "0.10.5" version = "0.10.5"
@ -1441,6 +1486,17 @@ version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "624a8340c38c1b80fd549087862da4ba43e08858af025b236e509b6649fc13d5" checksum = "624a8340c38c1b80fd549087862da4ba43e08858af025b236e509b6649fc13d5"
[[package]]
name = "open"
version = "5.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cfabf1927dce4d6fdf563d63328a0a506101ced3ec780ca2135747336c98cef8"
dependencies = [
"is-wsl",
"libc",
"pathdiff",
]
[[package]] [[package]]
name = "openidconnect" name = "openidconnect"
version = "3.4.0" version = "3.4.0"
@ -1473,6 +1529,12 @@ dependencies = [
"url", "url",
] ]
[[package]]
name = "option-ext"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d"
[[package]] [[package]]
name = "ordered-float" name = "ordered-float"
version = "2.10.1" version = "2.10.1"
@ -1529,6 +1591,12 @@ dependencies = [
"windows-targets", "windows-targets",
] ]
[[package]]
name = "pathdiff"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8835116a5c179084a830efb3adc117ab007512b535bc1a21c991d3b32a6b44dd"
[[package]] [[package]]
name = "pem" name = "pem"
version = "1.1.1" version = "1.1.1"

View file

@ -10,5 +10,10 @@ clap = { version="4.4", features = ["derive"] }
reqwest = { version="0.11", features = ["rustls-tls", "stream"], default-features=false} reqwest = { version="0.11", features = ["rustls-tls", "stream"], default-features=false}
openidconnect = "3.4" openidconnect = "3.4"
thiserror = "1.0" thiserror = "1.0"
confy = "0.5"
serde = { version="1.0", features = [ "derive" ] } serde = { version="1.0", features = [ "derive" ] }
axum = "0.6"
tokio = { version = "1.33", features = ["full"] }
open = "5.0"
tokio-util = { version="0.7.9", features = ["io"]}
dirs = "5.0"
confy = "0.5"

174
cli/src/auth.rs Normal file
View file

@ -0,0 +1,174 @@
use std::sync::Arc;
use axum::{
extract::{Query, State},
response::{Html, IntoResponse},
routing::get,
Router,
};
use openidconnect::{
core::{CoreAuthenticationFlow, CoreClient, CoreErrorResponseType, CoreProviderMetadata},
reqwest::async_http_client,
AccessTokenHash, AuthorizationCode, ClaimsVerificationError, ClientId, CsrfToken,
DiscoveryError, IssuerUrl, Nonce, OAuth2TokenResponse, PkceCodeChallenge, RedirectUrl,
RefreshToken, RequestTokenError, Scope, SigningError, StandardErrorResponse, TokenResponse,
};
use serde::Deserialize;
use thiserror::Error;
use tokio::sync::mpsc;
#[derive(Error, Debug)]
pub enum Error {
#[error("url parse error: {:?}", 0)]
UrlParse(#[from] openidconnect::url::ParseError),
#[error("discovery error: {:?}", 0)]
Discovery(#[from] DiscoveryError<openidconnect::reqwest::Error<reqwest::Error>>),
#[error("request token error: {:?}", 0)]
RequestToken(
#[from]
RequestTokenError<
openidconnect::reqwest::Error<reqwest::Error>,
StandardErrorResponse<CoreErrorResponseType>,
>,
),
#[error("claims verification error: {:?}", 0)]
ClaimsVerification(#[from] ClaimsVerificationError),
#[error("signing error: {:?}", 0)]
Signing(#[from] SigningError),
#[error("server did not return an id token")]
NoIdToken,
#[error("invalid access token")]
InvalidAccessToken,
#[error("no response received")]
NoResponse,
#[error("csrf mismatch")]
CsrfMismatch,
}
#[derive(Debug, Deserialize)]
struct ResponseData {
pub code: String,
pub state: String,
}
pub(crate) async fn login(
issuer: &str,
client_id: &str,
scopes: &[String],
refresh_token: &mut Option<String>,
) -> Result<String, Error> {
let provider_metadata = CoreProviderMetadata::discover_async(
IssuerUrl::new(issuer.to_string())?,
async_http_client,
)
.await?;
// Create an OpenID Connect client by specifying the client ID, client secret, authorization URL
// and token URL.
let client = CoreClient::from_provider_metadata(
provider_metadata,
ClientId::new(client_id.to_string()),
None,
)
// Set the URL the user will be redirected to after the authorization process.
.set_redirect_uri(RedirectUrl::new("http://[::1]:8080".to_string())?);
// Generate a PKCE challenge.
let (pkce_challenge, pkce_verifier) = PkceCodeChallenge::new_random_sha256();
if let Some(refresh_token) = refresh_token {
if let Ok(token_response) = client
.exchange_refresh_token(&RefreshToken::new(refresh_token.to_string()))
.request_async(async_http_client)
.await
{
eprintln!("authenticated with oidc provider");
return Ok(token_response.access_token().secret().clone());
}
}
// Generate the full authorization URL.
let mut auth = client.authorize_url(
CoreAuthenticationFlow::AuthorizationCode,
CsrfToken::new_random,
Nonce::new_random,
);
for scope in scopes {
auth = auth.add_scope(Scope::new(scope.to_string()));
}
let (auth_url, csrf_token, nonce) = auth
// Set the PKCE code challenge.
.set_pkce_challenge(pkce_challenge)
.url();
open::that(auth_url.to_string()).unwrap();
eprintln!("a browser should have been opened with the url {auth_url}. please login with your oidc provider.");
let (fuse_tx, mut fuse_rx) = mpsc::channel::<ResponseData>(1);
let app = Router::new()
.route("/", get(handle_post))
.with_state(Arc::new(fuse_tx));
let server = axum::Server::bind(&"[::1]:8080".parse().unwrap()).serve(app.into_make_service());
let data = tokio::select! {
x = fuse_rx.recv() => {
x
}
_ = server => {
None
}
};
let data = data.ok_or(Error::NoResponse)?;
// match csrf_state
if *csrf_token.secret() != data.state {
return Err(Error::CsrfMismatch);
}
let token_response = client
.exchange_code(AuthorizationCode::new(data.code))
// Set the PKCE code verifier.
.set_pkce_verifier(pkce_verifier)
.request_async(async_http_client)
.await?;
// Extract the ID token claims after verifying its authenticity and nonce.
let id_token = token_response.id_token().ok_or_else(|| Error::NoIdToken)?;
let claims = id_token.claims(&client.id_token_verifier(), &nonce)?;
// Verify the access token hash to ensure that the access token hasn't been substituted for
// another user's.
if let Some(expected_access_token_hash) = claims.access_token_hash() {
let actual_access_token_hash =
AccessTokenHash::from_token(token_response.access_token(), &id_token.signing_alg()?)?;
if actual_access_token_hash != *expected_access_token_hash {
return Err(Error::InvalidAccessToken);
}
}
if let Some(new_refresh_token) = token_response.refresh_token() {
*refresh_token = Some(new_refresh_token.secret().to_string());
}
eprintln!("authenticated with oidc provider");
Ok(token_response.access_token().secret().clone())
}
async fn handle_post(
State(fuse_tx): State<Arc<mpsc::Sender<ResponseData>>>,
Query(data): Query<ResponseData>,
) -> impl IntoResponse {
fuse_tx.clone().send(data).await;
Html("<html><body>Die Anmeldung war erfolgreich. Du kannst dieses Fenster jetzt schließen.<script>window.close()</script></body></html>")
}

View file

@ -1,57 +1,94 @@
use std::path::PathBuf; use clap::Parser;
use reqwest::{Body, Url};
use clap::{Parser, Subcommand};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use tokio::io::stdin;
use tokio_util::io::ReaderStream;
#[derive(Debug, Serialize, Deserialize)] use crate::auth::login;
mod auth;
#[derive(Serialize, Deserialize, Debug)]
pub struct Config { pub struct Config {
server: String, pub refresh_token: Option<String>,
client_id: String, pub binurl: String,
client_secret: String, pub issuer: String,
claims: Vec<String>, pub client_id: String,
challenge_port: u32, pub scopes: Vec<String>,
} }
#[derive(Debug, Parser)] #[derive(Debug, Parser)]
pub struct Args { pub struct Args {
#[arg(short, long, value_name = "FILE")] #[arg(short, long)]
config: Option<PathBuf>, content_type: Option<String>,
#[command(subcommand)] #[arg(short, long)]
command: Option<Command>, ttl: Option<String>,
} }
#[derive(Debug, Subcommand)] impl Default for Config {
pub enum Command { fn default() -> Self {
Create { Self {
#[arg(short, long, action)] refresh_token: None,
stdin: bool, binurl: "https://bin.zettoit.eu".to_string(),
}, issuer: "https://auth.zettoit.eu/realms/zettoit".to_string(),
Upload {}, client_id: "binctl".to_string(),
Login { scopes: vec!["zettoit-bin".to_string()],
/// challenge port to listen to }
#[arg(short, long, value_name = "PORT")] }
port: Option<u32>,
/// OIDC server
#[arg(long, value_name = "URL")]
server: Option<String>,
/// OIDC client id
#[arg(long)]
client: Option<String>,
/// OIDC client secret
#[arg(long)]
secret: Option<String>,
/// OIDC claims
#[arg(long)]
claims: Option<Vec<String>>,
},
} }
fn main() { #[tokio::main]
async fn main() {
let mut cfg: Config = confy::load("binctl", None).unwrap_or_default();
let args = Args::parse(); let args = Args::parse();
dbg!(args); let access_token = login(
&cfg.issuer,
&cfg.client_id,
cfg.scopes.as_slice(),
&mut cfg.refresh_token,
)
.await
.unwrap();
let mut bin = create_bin(&cfg.binurl, &access_token).await.unwrap();
eprintln!("created bin at {}. uploading...", bin);
bin.set_query(args.ttl.map(|x| format!("ttl={}", x)).as_deref());
upload_to_bin(
bin.as_ref(),
&args
.content_type
.unwrap_or("application/octet-stream".to_string()),
)
.await
.unwrap();
let _ = confy::store("binctl", None, cfg);
bin.set_query(None);
print!("{bin}");
}
async fn create_bin(binurl: &str, access_token: &str) -> Result<Url, reqwest::Error> {
let client = reqwest::Client::new();
Ok(client
.get(binurl)
.header("Authorization", format!("Bearer {}", access_token))
.send()
.await?
.url()
.to_owned())
}
async fn upload_to_bin(url: &str, content_type: &str) -> Result<(), reqwest::Error> {
let client = reqwest::Client::new();
client
.post(url)
.header("Content-Type", content_type)
.body(Body::wrap_stream(ReaderStream::new(stdin())))
.send()
.await?;
Ok(())
} }

View file

@ -26,20 +26,22 @@
nixpkgs.lib.genAttrs [ nixpkgs.lib.genAttrs [
"x86_64-linux" "x86_64-linux"
"aarch64-linux" "aarch64-linux"
] (system: let ] (system: function system nixpkgs.legacyPackages.${system});
in rec {
packages = forAllSystems(system: syspkgs: let
pkgs = import nixpkgs { pkgs = import nixpkgs {
inherit system; inherit system;
overlays = [ (import rust-overlay) ]; overlays = [ (import rust-overlay) ];
}; };
rustToolchain = pkgs.rust-bin.stable.latest.default; rustToolchain = pkgs.rust-bin.stable.latest.default;
markdownFilter = path: _type: builtins.match ".*md$" path != null;
markdownOrCargo = path: type: (markdownFilter path type) || (craneLib.filterCargoSources path type);
craneLib = (crane.mkLib pkgs).overrideToolchain rustToolchain; craneLib = (crane.mkLib pkgs).overrideToolchain rustToolchain;
src = pkgs.lib.cleanSourceWith { src = pkgs.lib.cleanSourceWith {
src = craneLib.path ./.; src = craneLib.path ./.;
filter = markdownOrCargo; filter = path: type:
(pkgs.lib.hasSuffix "\.md" path) ||
(craneLib.filterCargoSources path type)
;
}; };
nativeBuildInputs = with pkgs; [ rustToolchain pkg-config ]; nativeBuildInputs = with pkgs; [ rustToolchain pkg-config ];
@ -52,18 +54,20 @@
bin = craneLib.buildPackage (commonArgs // { bin = craneLib.buildPackage (commonArgs // {
inherit cargoArtifacts; inherit cargoArtifacts;
pname = "bin";
}); });
in function { binctl = craneLib.buildPackage (commonArgs // {
inherit bin pkgs; inherit cargoArtifacts;
}); pname = "binctl";
in { });
packages = forAllSystems({pkgs, bin}: { in {
inherit bin; inherit bin binctl;
default = bin; default = bin;
}); });
devShells = forAllSystems({pkgs, bin}: pkgs.mkShell { devShells = forAllSystems(system: pkgs: pkgs.mkShell {
inputsFrom = bin; inputsFrom = [packages.${system}.bin packages.${system}.binctl];
}); });
hydraJobs."build" = forAllSystems({pkgs, bin}: bin); hydraJobs."bin" = forAllSystems(system: pkgs: packages.${system}.bin);
hydraJobs."binctl" = forAllSystems(system: pkgs: packages.${system}.binctl);
}; };
} }