diff options
author | Jesse Morgan <jesse@jesterpm.net> | 2024-01-27 21:45:33 -0800 |
---|---|---|
committer | Jesse Morgan <jesse@jesterpm.net> | 2024-01-27 21:45:33 -0800 |
commit | 8df0ef0368dd3f6fdd2fccb5da458a1fe99f25e0 (patch) | |
tree | 55c873f7742411eb099f874b84d01327471bda62 /src |
Initial version of chkoauth2v0.1.0
Diffstat (limited to 'src')
-rw-r--r-- | src/error.rs | 68 | ||||
-rw-r--r-- | src/indieauth.rs | 21 | ||||
-rw-r--r-- | src/main.rs | 183 |
3 files changed, 272 insertions, 0 deletions
diff --git a/src/error.rs b/src/error.rs new file mode 100644 index 0000000..4ecd1a3 --- /dev/null +++ b/src/error.rs @@ -0,0 +1,68 @@ +use oauth2::http::{header, Response, StatusCode}; +use std::fmt::{Display, Formatter}; +use std::io; + +#[derive(Debug)] +#[allow(clippy::enum_variant_names)] +pub enum Error { + IoError(io::Error), + MissingToken, + InvalidToken, + ConfigurationError, + IntrospectionServerError, + AccessDenied, +} + +impl Display for Error { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + f.write_str(match self { + Error::IoError(e) => return write!(f, "IO Error: {}", e), + Error::AccessDenied => "Access denied", + Error::MissingToken => "Missing authorization token", + Error::InvalidToken => "Invalid access token", + Error::ConfigurationError => "OAuth2 client configuration error", + Error::IntrospectionServerError => { + "Introspection endpoint returned an error" + } + }) + } +} + +impl From<&Error> for StatusCode { + fn from(e: &Error) -> StatusCode { + match e { + Error::IoError(_) => StatusCode::INTERNAL_SERVER_ERROR, + Error::AccessDenied => StatusCode::FORBIDDEN, + Error::MissingToken => StatusCode::UNAUTHORIZED, + Error::InvalidToken => StatusCode::UNAUTHORIZED, + Error::ConfigurationError => StatusCode::INTERNAL_SERVER_ERROR, + Error::IntrospectionServerError => StatusCode::SERVICE_UNAVAILABLE, + } + } +} + +impl From<Error> for Response<Option<String>> { + fn from(e: Error) -> Response<Option<String>> { + let resp = Response::builder().status(StatusCode::from(&e)); + match e { + Error::IoError(_) => resp.body(None).unwrap(), + Error::AccessDenied => resp + .header(header::WWW_AUTHENTICATE, "Bearer") + .body(Some("{\"error\": \"insufficient_scope\"}".to_string())) + .unwrap(), + Error::MissingToken => resp.body(None).unwrap(), + Error::InvalidToken => resp + .header(header::WWW_AUTHENTICATE, "Bearer") + .body(Some("{\"error\": \"invalid_token\"}".to_string())) + .unwrap(), + Error::ConfigurationError => resp.body(None).unwrap(), + Error::IntrospectionServerError => resp.body(None).unwrap(), + } + } +} + +impl From<io::Error> for Error { + fn from(e: io::Error) -> Self { + Error::IoError(e) + } +} diff --git a/src/indieauth.rs b/src/indieauth.rs new file mode 100644 index 0000000..0f04b84 --- /dev/null +++ b/src/indieauth.rs @@ -0,0 +1,21 @@ +//! Extras for working with IndieAuth endpoints. + +use oauth2::ExtraTokenFields; +use serde::{Deserialize, Serialize}; + +/// An IndieAuth access token and introspection reponse has an additional +/// `me` field. +/// +/// See https://indieauth.spec.indieweb.org/#access-token-verification +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct IndieAuthToken { + me: String, +} + +impl IndieAuthToken { + pub fn me(&self) -> &str { + &self.me + } +} + +impl ExtraTokenFields for IndieAuthToken {} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..ec91ec1 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,183 @@ +use clap::Parser; +use oauth2::basic::BasicErrorResponseType; +use oauth2::http::Response; +use oauth2::url::Url; +use oauth2::{ + basic::BasicTokenType, reqwest, AccessToken, AuthUrl, ClientId, + ClientSecret, IntrospectionUrl, StandardErrorResponse, + StandardRevocableToken, StandardTokenIntrospectionResponse, + StandardTokenResponse, TokenIntrospectionResponse, +}; +use std::collections::HashSet; +use std::io::Write; +use std::process::Command; +use std::{env, io, process}; + +#[cfg(feature = "indieauth")] +mod indieauth; + +mod error; +use error::Error; + +/// A CGI wrapper to validate OAuth2 bearer tokens before executing another +/// script. +#[derive(Parser, Debug)] +struct Args { + /// The OAuth2 client id to authenticate with the introspection endpoint. + /// This may also be provided through the OAUTH2_CLIENT_ID environment + /// variable. + #[arg(long = "client-id")] + client_id: Option<String>, + + /// The OAuth2 client secret. + /// This may also be provided through the OAUTH2_CLIENT_SECRET environment + /// variable. + #[arg(long = "secret")] + client_secret: Option<String>, + /// Scopes that must be present for the request to succeed. + #[arg(long = "scope")] + scope: Vec<String>, + + /// The URL of the Authorization endpoint. + auth_url: Url, + + /// The URL of the token introspection endpoint. + introspection_url: Url, + + /// The command to run if authorized. + command: String, + + /// Arguments to pass to the command. + args: Vec<String>, +} + +const BEARER_TOKEN_PREFIX: &str = "Bearer "; + +#[cfg(feature = "indieauth")] +type TokenType = indieauth::IndieAuthToken; +#[cfg(not(feature = "indieauth"))] +type TokenType = oauth2::EmptyExtraTokenFields; + +type Client = oauth2::Client< + StandardErrorResponse<BasicErrorResponseType>, + StandardTokenResponse<TokenType, BasicTokenType>, + BasicTokenType, + StandardTokenIntrospectionResponse<TokenType, BasicTokenType>, + StandardRevocableToken, + StandardErrorResponse<BasicErrorResponseType>, +>; + +fn handle_request() -> Result<Response<Option<String>>, Error> { + let args = Args::parse(); + + let client_id = args + .client_id + .or_else(|| env::var("OAUTH2_CLIENT_ID").ok()) + .map(ClientId::new) + .expect("Missing required argument --client-id"); + + let client_secret = args + .client_secret + .or_else(|| env::var("OAUTH2_CLIENT_SECRET").ok()) + .map(ClientSecret::new); + + let client = Client::new( + client_id, + client_secret, + AuthUrl::from_url(args.auth_url), + None, + ) + .set_introspection_uri(IntrospectionUrl::from_url(args.introspection_url)); + + let access_token = env::var("HTTP_AUTHORIZATION") + .ok() + .filter(|value| value.starts_with(BEARER_TOKEN_PREFIX)) + .map(|value| { + AccessToken::new( + value.split_at(BEARER_TOKEN_PREFIX.len()).1.to_string(), + ) + }) + .ok_or_else(|| { + log::info!("Authorization failed due to missing or malformed Authorization header"); + Error::MissingToken + })?; + + let resp = client + .introspect(&access_token) + .map_err(|e| { + log::error!("OAuth2 client configuration error: {}", e); + Error::ConfigurationError + })? + .request(reqwest::http_client) + .map_err(|e| { + log::warn!("Error from token introspection service: {:?}", e); + Error::IntrospectionServerError + }) + .and_then(|resp| { + if resp.active() { + Ok(resp) + } else { + log::info!("Authorization failed due to invalid token"); + Err(Error::InvalidToken) + } + })?; + + let authorized_scopes: HashSet<&str> = resp + .scopes() + .iter() + .flat_map(|s| s.iter()) + .map(|s| s.as_ref()) + .collect(); + + // Check if all required scopes are granted by the token. + if !args + .scope + .iter() + .all(|s| authorized_scopes.contains(s.as_str())) + { + log::info!("Authorization failed due to missing required scope(s)"); + return Err(Error::AccessDenied); + } + + let scopes = authorized_scopes.iter().fold(String::new(), |mut a, b| { + a.push_str(b); + a.push(' '); + a + }); + + let mut cmd = Command::new(args.command); + cmd.args(args.args); + cmd.env("OAUTH2_SCOPES", scopes.trim_end()); + + #[cfg(feature = "indieauth")] + cmd.env("INDIEAUTH_ME", resp.extra_fields().me()); + + let status = cmd.status()?.code().unwrap_or(0); + + process::exit(status) +} + +fn main() { + env_logger::init(); + + let resp = handle_request().unwrap_or_else(|e| e.into()); + println!( + "Status: {} {}", + resp.status().as_str(), + resp.status().canonical_reason().unwrap_or("") + ); + + for (name, value) in resp.headers().iter() { + print!("{}: ", name); + io::stdout().write_all(value.as_bytes()).expect("IO Error"); + println!(); + } + + if let Some(body) = resp.body() { + println!("Content-Type: application/json"); + println!(); + println!("{}", body) + } else { + println!(); + } +} |