-
Notifications
You must be signed in to change notification settings - Fork 331
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Introduce UserAuthStrategy to allow third party authentication implem…
…entation Co-authored-by: Jeremy Rowe <[email protected]>
- Loading branch information
1 parent
8fd841d
commit 5b0c509
Showing
22 changed files
with
784 additions
and
574 deletions.
There are no files selected for viewing
This file was deleted.
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
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,93 @@ | ||
use crate::auth::{constants::GRPC_PROXY_AUTH_HEADER, Authorized, Permission}; | ||
use crate::namespace::NamespaceName; | ||
use libsql_replication::rpc::replication::NAMESPACE_METADATA_KEY; | ||
use tonic::Status; | ||
|
||
/// A witness that the user has been authenticated. | ||
#[non_exhaustive] | ||
#[derive(Clone, Debug, PartialEq, Eq)] | ||
pub enum Authenticated { | ||
Anonymous, | ||
Authorized(Authorized), | ||
} | ||
|
||
impl Authenticated { | ||
pub fn from_proxy_grpc_request<T>( | ||
req: &tonic::Request<T>, | ||
disable_namespace: bool, | ||
) -> Result<Self, Status> { | ||
let namespace = if disable_namespace { | ||
None | ||
} else { | ||
req.metadata() | ||
.get_bin(NAMESPACE_METADATA_KEY) | ||
.map(|c| c.to_bytes()) | ||
.transpose() | ||
.map_err(|_| Status::invalid_argument("failed to parse namespace header"))? | ||
.map(NamespaceName::from_bytes) | ||
.transpose() | ||
.map_err(|_| Status::invalid_argument("invalid namespace name"))? | ||
}; | ||
|
||
let auth = match req | ||
.metadata() | ||
.get(GRPC_PROXY_AUTH_HEADER) | ||
.map(|v| v.to_str()) | ||
.transpose() | ||
.map_err(|_| Status::invalid_argument("missing authorization header"))? | ||
{ | ||
Some("full_access") => Authenticated::Authorized(Authorized { | ||
namespace, | ||
permission: Permission::FullAccess, | ||
}), | ||
Some("read_only") => Authenticated::Authorized(Authorized { | ||
namespace, | ||
permission: Permission::ReadOnly, | ||
}), | ||
Some("anonymous") => Authenticated::Anonymous, | ||
Some(level) => { | ||
return Err(Status::permission_denied(format!( | ||
"invalid authorization level: {}", | ||
level | ||
))) | ||
} | ||
None => return Err(Status::invalid_argument("x-proxy-authorization not set")), | ||
}; | ||
|
||
Ok(auth) | ||
} | ||
|
||
pub fn upgrade_grpc_request<T>(&self, req: &mut tonic::Request<T>) { | ||
let key = tonic::metadata::AsciiMetadataKey::from_static(GRPC_PROXY_AUTH_HEADER); | ||
|
||
let auth = match self { | ||
Authenticated::Anonymous => "anonymous", | ||
Authenticated::Authorized(Authorized { | ||
permission: Permission::FullAccess, | ||
.. | ||
}) => "full_access", | ||
Authenticated::Authorized(Authorized { | ||
permission: Permission::ReadOnly, | ||
.. | ||
}) => "read_only", | ||
}; | ||
|
||
let value = tonic::metadata::AsciiMetadataValue::try_from(auth).unwrap(); | ||
|
||
req.metadata_mut().insert(key, value); | ||
} | ||
|
||
pub fn is_namespace_authorized(&self, namespace: &NamespaceName) -> bool { | ||
match self { | ||
Authenticated::Anonymous => false, | ||
Authenticated::Authorized(Authorized { | ||
namespace: Some(ns), | ||
.. | ||
}) => ns == namespace, | ||
// we threat the absence of a specific namespace has a permission to any namespace | ||
Authenticated::Authorized(Authorized { | ||
namespace: None, .. | ||
}) => true, | ||
} | ||
} | ||
} |
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,8 @@ | ||
use crate::auth::Permission; | ||
use crate::namespace::NamespaceName; | ||
|
||
#[derive(Clone, Debug, PartialEq, Eq)] | ||
pub struct Authorized { | ||
pub namespace: Option<NamespaceName>, | ||
pub permission: Permission, | ||
} |
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,2 @@ | ||
pub(crate) static GRPC_AUTH_HEADER: &str = "x-authorization"; | ||
pub(crate) static GRPC_PROXY_AUTH_HEADER: &str = "x-proxy-authorization"; |
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,51 @@ | ||
use tonic::Status; | ||
|
||
#[derive(thiserror::Error, Debug, PartialEq)] | ||
pub enum AuthError { | ||
#[error("The `Authorization` HTTP header is required but was not specified")] | ||
HttpAuthHeaderMissing, | ||
#[error("The `Authorization` HTTP header has invalid value")] | ||
HttpAuthHeaderInvalid, | ||
#[error("The authentication scheme in the `Authorization` HTTP header is not supported")] | ||
HttpAuthHeaderUnsupportedScheme, | ||
#[error("The `Basic` HTTP authentication scheme is not allowed")] | ||
BasicNotAllowed, | ||
#[error("The `Basic` HTTP authentication credentials were rejected")] | ||
BasicRejected, | ||
#[error("Authentication is required but no JWT was specified")] | ||
JwtMissing, | ||
#[error("Authentication using a JWT is not allowed")] | ||
JwtNotAllowed, | ||
#[error("The JWT is invalid")] | ||
JwtInvalid, | ||
#[error("The JWT has expired")] | ||
JwtExpired, | ||
#[error("The JWT is immature (not valid yet)")] | ||
JwtImmature, | ||
#[error("Authentication failed")] | ||
Other, | ||
} | ||
|
||
impl AuthError { | ||
pub fn code(&self) -> &'static str { | ||
match self { | ||
Self::HttpAuthHeaderMissing => "AUTH_HTTP_HEADER_MISSING", | ||
Self::HttpAuthHeaderInvalid => "AUTH_HTTP_HEADER_INVALID", | ||
Self::HttpAuthHeaderUnsupportedScheme => "AUTH_HTTP_HEADER_UNSUPPORTED_SCHEME", | ||
Self::BasicNotAllowed => "AUTH_BASIC_NOT_ALLOWED", | ||
Self::BasicRejected => "AUTH_BASIC_REJECTED", | ||
Self::JwtMissing => "AUTH_JWT_MISSING", | ||
Self::JwtNotAllowed => "AUTH_JWT_NOT_ALLOWED", | ||
Self::JwtInvalid => "AUTH_JWT_INVALID", | ||
Self::JwtExpired => "AUTH_JWT_EXPIRED", | ||
Self::JwtImmature => "AUTH_JWT_IMMATURE", | ||
Self::Other => "AUTH_FAILED", | ||
} | ||
} | ||
} | ||
|
||
impl From<AuthError> for Status { | ||
fn from(e: AuthError) -> Self { | ||
Status::unauthenticated(format!("AuthError: {}", e)) | ||
} | ||
} |
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,18 @@ | ||
use std::sync::Arc; | ||
|
||
pub mod authenticated; | ||
pub mod authorized; | ||
pub mod constants; | ||
pub mod errors; | ||
pub mod parsers; | ||
pub mod permission; | ||
pub mod user_auth_strategies; | ||
|
||
pub use authenticated::Authenticated; | ||
pub use authorized::Authorized; | ||
pub use errors::AuthError; | ||
pub use parsers::{parse_http_auth_header, parse_http_basic_auth_arg, parse_jwt_key}; | ||
pub use permission::Permission; | ||
pub use user_auth_strategies::{Disabled, HttpBasic, Jwt, UserAuthContext, UserAuthStrategy}; | ||
|
||
pub type Auth = Arc<dyn UserAuthStrategy>; |
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,114 @@ | ||
use crate::auth::{constants::GRPC_AUTH_HEADER, AuthError}; | ||
|
||
use anyhow::{bail, Context as _, Result}; | ||
use axum::http::HeaderValue; | ||
use tonic::metadata::MetadataMap; | ||
|
||
pub fn parse_http_basic_auth_arg(arg: &str) -> Result<Option<String>> { | ||
if arg == "always" { | ||
return Ok(None); | ||
} | ||
|
||
let Some((scheme, param)) = arg.split_once(':') else { | ||
bail!("invalid HTTP auth config: {arg}") | ||
}; | ||
|
||
if scheme == "basic" { | ||
Ok(Some(param.into())) | ||
} else { | ||
bail!("unsupported HTTP auth scheme: {scheme:?}") | ||
} | ||
} | ||
|
||
pub fn parse_jwt_key(data: &str) -> Result<jsonwebtoken::DecodingKey> { | ||
if data.starts_with("-----BEGIN PUBLIC KEY-----") { | ||
jsonwebtoken::DecodingKey::from_ed_pem(data.as_bytes()) | ||
.context("Could not decode Ed25519 public key from PEM") | ||
} else if data.starts_with("-----BEGIN PRIVATE KEY-----") { | ||
bail!("Received a private key, but a public key is expected") | ||
} else if data.starts_with("-----BEGIN") { | ||
bail!("Key is in unsupported PEM format") | ||
} else { | ||
jsonwebtoken::DecodingKey::from_ed_components(data) | ||
.context("Could not decode Ed25519 public key from base64") | ||
} | ||
} | ||
|
||
pub(crate) fn parse_grpc_auth_header(metadata: &MetadataMap) -> Option<HeaderValue> { | ||
metadata | ||
.get(GRPC_AUTH_HEADER) | ||
.map(|v| v.to_bytes().expect("Auth should always be ASCII")) | ||
.map(|v| HeaderValue::from_maybe_shared(v).expect("Should already be valid header")) | ||
} | ||
|
||
pub fn parse_http_auth_header<'a>( | ||
expected_scheme: &str, | ||
auth_header: &'a Option<HeaderValue>, | ||
) -> Result<&'a str, AuthError> { | ||
let Some(header) = auth_header else { | ||
return Err(AuthError::HttpAuthHeaderMissing); | ||
}; | ||
|
||
let Ok(header) = header.to_str() else { | ||
return Err(AuthError::HttpAuthHeaderInvalid); | ||
}; | ||
|
||
let Some((scheme, param)) = header.split_once(' ') else { | ||
return Err(AuthError::HttpAuthHeaderInvalid); | ||
}; | ||
|
||
if !scheme.eq_ignore_ascii_case(expected_scheme) { | ||
return Err(AuthError::HttpAuthHeaderUnsupportedScheme); | ||
} | ||
|
||
Ok(param) | ||
} | ||
|
||
#[cfg(test)] | ||
mod tests { | ||
use axum::http::HeaderValue; | ||
use hyper::header::AUTHORIZATION; | ||
|
||
use crate::auth::{parse_http_auth_header, AuthError}; | ||
|
||
#[test] | ||
fn parse_http_auth_header_returns_auth_header_param_when_valid() { | ||
assert_eq!( | ||
parse_http_auth_header("basic", &HeaderValue::from_str("Basic abc").ok()).unwrap(), | ||
"abc" | ||
) | ||
} | ||
|
||
#[test] | ||
fn parse_http_auth_header_errors_when_auth_header_missing() { | ||
assert_eq!( | ||
parse_http_auth_header("basic", &None).unwrap_err(), | ||
AuthError::HttpAuthHeaderMissing | ||
) | ||
} | ||
|
||
#[test] | ||
fn parse_http_auth_header_errors_when_auth_header_cannot_be_converted_to_str() { | ||
assert_eq!( | ||
parse_http_auth_header("basic", &Some(HeaderValue::from_name(AUTHORIZATION))) | ||
.unwrap_err(), | ||
AuthError::HttpAuthHeaderInvalid | ||
) | ||
} | ||
|
||
#[test] | ||
fn parse_http_auth_header_errors_when_auth_header_invalid_format() { | ||
assert_eq!( | ||
parse_http_auth_header("basic", &HeaderValue::from_str("invalid").ok()).unwrap_err(), | ||
AuthError::HttpAuthHeaderInvalid | ||
) | ||
} | ||
|
||
#[test] | ||
fn parse_http_auth_header_errors_when_auth_header_is_unsupported_scheme() { | ||
assert_eq!( | ||
parse_http_auth_header("basic", &HeaderValue::from_str("Bearer abc").ok()).unwrap_err(), | ||
AuthError::HttpAuthHeaderUnsupportedScheme | ||
) | ||
} | ||
} |
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,6 @@ | ||
#[non_exhaustive] | ||
#[derive(Clone, Copy, Debug, PartialEq, Eq)] | ||
pub enum Permission { | ||
FullAccess, | ||
ReadOnly, | ||
} |
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,46 @@ | ||
use super::{UserAuthContext, UserAuthStrategy}; | ||
use crate::auth::{AuthError, Authenticated, Authorized, Permission}; | ||
|
||
pub struct Disabled {} | ||
|
||
impl UserAuthStrategy for Disabled { | ||
fn authenticate(&self, _context: UserAuthContext) -> Result<Authenticated, AuthError> { | ||
tracing::info!("executing disabled auth"); | ||
|
||
Ok(Authenticated::Authorized(Authorized { | ||
namespace: None, | ||
permission: Permission::FullAccess, | ||
})) | ||
} | ||
} | ||
|
||
impl Disabled { | ||
pub fn new() -> Self { | ||
Self {} | ||
} | ||
} | ||
|
||
#[cfg(test)] | ||
mod tests { | ||
use crate::namespace::NamespaceName; | ||
|
||
use super::*; | ||
|
||
#[test] | ||
fn authenticates() { | ||
let strategy = Disabled::new(); | ||
let context = UserAuthContext { | ||
namespace: NamespaceName::default(), | ||
namespace_credential: None, | ||
user_credential: None, | ||
}; | ||
|
||
assert_eq!( | ||
strategy.authenticate(context).unwrap(), | ||
Authenticated::Authorized(Authorized { | ||
namespace: None, | ||
permission: Permission::FullAccess, | ||
}) | ||
) | ||
} | ||
} |
Oops, something went wrong.