-
Notifications
You must be signed in to change notification settings - Fork 4
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
refactor: refactor out JWT authentication for REST
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
Showing
19 changed files
with
429 additions
and
160 deletions.
There are no files selected for viewing
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
Oops, something went wrong.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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>() | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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(), | ||
} | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.