Skip to content

Commit

Permalink
refactor: refactor out JWT authentication for REST
Browse files Browse the repository at this point in the history
REST is going to require admin authentication, instead of the JWT token signing we do now just for valid client hashes. So we need to move the authentication to a more reusable form.

This has a couple incidental changes like introducing `secrecy` which lets us shred secrets in memory when they go out of scope. Minor benefit, but I added it for the password secrets so may as well use it for JWT too.
  • Loading branch information
Zoe Spellman committed Mar 8, 2024
1 parent c474ead commit 2da8a83
Show file tree
Hide file tree
Showing 19 changed files with 429 additions and 160 deletions.
44 changes: 44 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ version = "0.0.3"
edition = "2021"

[dependencies]
argon2 = "0.5.3"
axum = "0.6.20" # this *must* be pinned because 0.7.x relies on hyper 1.x causing a ton of type conversion issues
chrono = { version = "0.4.34", default-features = false, features = [
"std",
Expand All @@ -26,6 +27,7 @@ prost = "0.12"
prost-types = "0.12"
rand = "0.8"
reqwest = "0.11"
secrecy = { version = "0.8.0", features = ["serde"] }
serde = { version = "1.0", features = ["derive"] }
sha2 = "0.10"
snapd = { git = "https://github.com/ZoopOTheGoop/snapd-rs", branch = "framework" }
Expand Down
121 changes: 121 additions & 0 deletions src/app/interfaces/authentication/jwt.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
//! JSON Web Tokens infrastructure and utlities.
use jsonwebtoken::{DecodingKey, Validation};
use secrecy::{ExposeSecret, SecretString};
use serde::Deserialize;
use thiserror::Error;
use tracing::error;

use crate::utils::{jwt::Claims, Config};

use super::CredentialVerifier;

/// An error for things that can go wrong with JWT verification
#[derive(Error, Debug)]
#[allow(clippy::missing_docs_in_private_items, missing_docs)]
pub enum JwtVerifierError {
#[error("jwt: invalid shape")]
InvalidShape,
#[error("jwt: could not retrieve secret fron environment: {0}")]
EnvError(#[from] envy::Error),
#[error("jwt: error decoding secret: {0}")]
DecodeSecretError(#[from] jsonwebtoken::errors::Error),
#[error("jwt: invalid authz token")]
InvalidHeader,
}

impl From<JwtVerifierError> for tonic::Status {
fn from(value: JwtVerifierError) -> Self {
match value {
JwtVerifierError::InvalidShape | JwtVerifierError::EnvError(_) => {
tonic::Status::internal("Internal Server Error")
}
JwtVerifierError::DecodeSecretError(_) => {
tonic::Status::unauthenticated("invalid JWT token")
}
JwtVerifierError::InvalidHeader => {
tonic::Status::unauthenticated("invalid authz header")
}
}
}
}

#[derive(Clone)]
/// A JWT verification agent that allows verifying assigned tokens are valid
pub struct JwtVerifier {
/// A decoding key for receipt
decoding_key: DecodingKey,
}

impl JwtVerifier {
/// Attempts to create a new verifier from the invoker's environment.
pub fn from_env() -> Result<Self, JwtVerifierError> {
let config = JwtConfig::from_env()?;

Self::from_secret(&config.jwt_secret)
}

/// Creates a new verifier from the given secret.
pub fn from_secret(secret: &SecretString) -> Result<Self, JwtVerifierError> {
let decoding_key = DecodingKey::from_base64_secret(secret.expose_secret())?;

Ok(Self { decoding_key })
}

/// Loads this verifier from the secret enclosed in [`Config`].
#[allow(dead_code)]
pub fn from_config(cfg: &Config) -> Result<Self, JwtVerifierError> {
Self::from_secret(&cfg.jwt_secret)
}

/// Decodes a given token received
pub fn decode(&self, token: &str) -> Result<Claims, JwtVerifierError> {
jsonwebtoken::decode::<Claims>(token, &self.decoding_key, &Validation::default())
.map(|t| t.claims)
.map_err(|e| {
error!("{e:?}");
JwtVerifierError::InvalidShape
})
}
}

impl CredentialVerifier for JwtVerifier {
type Extension = Claims;

type Error = JwtVerifierError;

fn verify(
&self,
credential: &hyper::header::HeaderValue,
) -> Result<Option<Self::Extension>, Self::Error> {
let raw: Vec<&str> = credential
.to_str()
.unwrap_or("")
.split_whitespace()
.collect();

if raw.len() != 2 {
error!("{}", JwtVerifierError::InvalidHeader);
return Err(JwtVerifierError::InvalidHeader);
}

let token = raw[1];
self.decode(token).map(Some)
}
}

/// A configuration only containing a JWT secret, just used for fast
/// on-the-fly construction with `from_env``
#[derive(Deserialize)]
#[allow(clippy::missing_docs_in_private_items)]
struct JwtConfig {
jwt_secret: SecretString,
}

impl JwtConfig {
/// Loads the secret from the environment
fn from_env() -> Result<JwtConfig, envy::Error> {
dotenvy::dotenv().ok();

envy::prefixed("APP_").from_env::<JwtConfig>()
}
}
136 changes: 136 additions & 0 deletions src/app/interfaces/authentication/mod.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
//! Contains a generic authenticator implementation for use in different backends
use hyper::header::{self, HeaderValue};
use tonic::Status;
use tracing::error;

pub mod jwt;

/// Verifies the given credentials, this is only really ever used by [`Authenticator`]
/// and unless you're adding a new auth method or endpoint, is probably useless to you.
pub trait CredentialVerifier {
/// Any extensions that will be added to the request's extensions field before being
/// passed down to the handler, should authentication succeed.
type Extension: Send + Sync;
/// Any errors that can be encountered during the verification procedure. These must
/// be convertable to [`tonic::Status`] values, especially so anything sensitive can be
/// erased before sending the error back to the client.
type Error: Into<tonic::Status> + std::error::Error;

/// Verifies the passed in header has the authentication values necessary. This does
/// NOT need to verify paths, nor that the header is actually an authorization header,
/// the [`Authenticator`] does that already. However, it should validate things like
/// header length, authentication type (Basic, etc) on its own.
fn verify(&self, credential: &HeaderValue) -> Result<Option<Self::Extension>, Self::Error>;
}

/// A utility meant to verify requests.
#[derive(Debug, Clone)]
pub struct Authenticator<V, A> {
/// The paths that can be accessed without verifying whether a user is authorized
public_paths: Vec<A>,
/// A [`CredentialVerifier`] that will check if the auth header is valid for non-public paths.
verifier: V,
}

impl<V: CredentialVerifier, A: AsRef<str>> Authenticator<V, A>
where
V: CredentialVerifier,
V::Extension: 'static,
A: AsRef<str>,
{
/// Attempts to authenticate the request's Authorization Header with the underlying [`CredentialVerifier`],
/// unless the URL path was designated as public during construction.
pub fn authenticate(&self, req: &mut hyper::Request<hyper::Body>) -> Result<(), tonic::Status> {
let uri = req.uri().to_string();

if self.public_paths.iter().any(|s| uri.ends_with(s.as_ref())) {
return Ok(());
}

let Some(header) = req.headers().get(header::AUTHORIZATION.as_str()) else {
let error = Status::unauthenticated("missing authz header");
error!("{error}");
return Err(error);
};

let extension = self
.verifier
.verify(header)
.inspect_err(|err| error!("{err}"))
.map_err(Into::into)?;

if let Some(extension) = extension {
req.extensions_mut().insert(extension);
}
Ok(())
}
}

/// Builder pattern for [`Authenticator`], this is mostly used for iteratively adding
/// new public paths.
#[allow(clippy::missing_docs_in_private_items)]
pub struct AuthenticatorBuilder<V, A> {
verifier: V,
public_paths: Vec<A>,
}

impl<V: CredentialVerifier, A> AuthenticatorBuilder<V, A> {
/// Constructs a new in-progress authenticator from the given [`CredentialVerifier`].
pub fn new(verifier: V) -> Self {
Self {
verifier,
public_paths: Vec::new(),
}
}

/// Overrides an existing [`CredentialVerifier`] with a new one.
#[allow(dead_code)]
pub fn override_verifier<V2: CredentialVerifier>(
self,
verifier: V2,
) -> AuthenticatorBuilder<V2, A> {
AuthenticatorBuilder {
verifier,
public_paths: self.public_paths,
}
}
}

impl<V, A: AsRef<str>> AuthenticatorBuilder<V, A> {
/// Adds a new path that the constructed [`Authenticator`] will consider public,
/// and thus pass without attempting to authenticate the passed header.
pub fn with_public_path(mut self, path: A) -> Self {
self.public_paths.push(path);
self
}

/// Like [AuthenticatorBuilder::with_public_path], but adds multiple at once. This
/// is slightly more efficient (and cleaner) than doing it yourself in a loop.
pub fn with_public_paths<I: Iterator<Item = A>>(mut self, paths: I) -> Self {
self.public_paths.extend(paths);
self
}
}

impl<V: CredentialVerifier, A: AsRef<str>> AuthenticatorBuilder<V, A> {
/// Renders this builder into an [`Authenticator`], ready to be used.
pub fn build(self) -> Authenticator<V, A> {
Authenticator {
verifier: self.verifier,
public_paths: self.public_paths,
}
}
}

impl<V, A> Default for AuthenticatorBuilder<V, A>
where
V: Default,
{
fn default() -> Self {
Self {
verifier: Default::default(),
public_paths: Default::default(),
}
}
}
1 change: 1 addition & 0 deletions src/app/interfaces/mod.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
//! Contains submodules with various middleware and utility layers for the app.
pub mod authentication;
pub mod middleware;
pub mod servers;

Expand Down
Loading

0 comments on commit 2da8a83

Please sign in to comment.