Skip to content

Commit

Permalink
Introduce UserAuthStrategy to allow third party authentication implem…
Browse files Browse the repository at this point in the history
…entation

Co-authored-by: Jeremy Rowe <[email protected]>
  • Loading branch information
jameswritescode and jeremywrowe committed Feb 16, 2024
1 parent 8fd841d commit 5b0c509
Show file tree
Hide file tree
Showing 22 changed files with 784 additions and 574 deletions.
473 changes: 0 additions & 473 deletions libsql-server/src/auth.rs

This file was deleted.

93 changes: 93 additions & 0 deletions libsql-server/src/auth/authenticated.rs
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,
}
}
}
8 changes: 8 additions & 0 deletions libsql-server/src/auth/authorized.rs
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,
}
2 changes: 2 additions & 0 deletions libsql-server/src/auth/constants.rs
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";
51 changes: 51 additions & 0 deletions libsql-server/src/auth/errors.rs
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))
}
}
18 changes: 18 additions & 0 deletions libsql-server/src/auth/mod.rs
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>;
114 changes: 114 additions & 0 deletions libsql-server/src/auth/parsers.rs
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
)
}
}
6 changes: 6 additions & 0 deletions libsql-server/src/auth/permission.rs
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,
}
46 changes: 46 additions & 0 deletions libsql-server/src/auth/user_auth_strategies/disabled.rs
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,
})
)
}
}
Loading

0 comments on commit 5b0c509

Please sign in to comment.