summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorJesse Morgan <jesse@jesterpm.net>2024-01-27 21:45:33 -0800
committerJesse Morgan <jesse@jesterpm.net>2024-01-27 21:45:33 -0800
commit8df0ef0368dd3f6fdd2fccb5da458a1fe99f25e0 (patch)
tree55c873f7742411eb099f874b84d01327471bda62 /src
Initial version of chkoauth2v0.1.0
Diffstat (limited to 'src')
-rw-r--r--src/error.rs68
-rw-r--r--src/indieauth.rs21
-rw-r--r--src/main.rs183
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!();
+ }
+}