Skip to content

Commit 9de3ccc

Browse files
MarinPostmajameswritescodeJeremy Rowejeremywrowe
authored
auth strategy2 (#1072)
* Introduce UserAuthStrategy to allow third party authentication implementation Co-authored-by: Jeremy Rowe <jeremy.rowe@shopify.com> * PR feedback * fix broken import * fmt --------- Co-authored-by: James Newton <hello@jamesnewton.com> Co-authored-by: Jeremy Rowe <jeremy.rowe@shopify.com> Co-authored-by: Jeremy W. Rowe <jeremywrowe@users.noreply.github.com>
1 parent 807b45c commit 9de3ccc

22 files changed

Lines changed: 797 additions & 574 deletions

libsql-server/src/auth.rs

Lines changed: 0 additions & 473 deletions
This file was deleted.
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
use crate::auth::{constants::GRPC_PROXY_AUTH_HEADER, Authorized, Permission};
2+
use crate::namespace::NamespaceName;
3+
use libsql_replication::rpc::replication::NAMESPACE_METADATA_KEY;
4+
use tonic::Status;
5+
6+
/// A witness that the user has been authenticated.
7+
#[non_exhaustive]
8+
#[derive(Clone, Debug, PartialEq, Eq)]
9+
pub enum Authenticated {
10+
Anonymous,
11+
Authorized(Authorized),
12+
}
13+
14+
impl Authenticated {
15+
pub fn from_proxy_grpc_request<T>(
16+
req: &tonic::Request<T>,
17+
disable_namespace: bool,
18+
) -> Result<Self, Status> {
19+
let namespace = if disable_namespace {
20+
None
21+
} else {
22+
req.metadata()
23+
.get_bin(NAMESPACE_METADATA_KEY)
24+
.map(|c| c.to_bytes())
25+
.transpose()
26+
.map_err(|_| Status::invalid_argument("failed to parse namespace header"))?
27+
.map(NamespaceName::from_bytes)
28+
.transpose()
29+
.map_err(|_| Status::invalid_argument("invalid namespace name"))?
30+
};
31+
32+
let auth = match req
33+
.metadata()
34+
.get(GRPC_PROXY_AUTH_HEADER)
35+
.map(|v| v.to_str())
36+
.transpose()
37+
.map_err(|_| Status::invalid_argument("missing authorization header"))?
38+
{
39+
Some("full_access") => Authenticated::Authorized(Authorized {
40+
namespace,
41+
permission: Permission::FullAccess,
42+
}),
43+
Some("read_only") => Authenticated::Authorized(Authorized {
44+
namespace,
45+
permission: Permission::ReadOnly,
46+
}),
47+
Some("anonymous") => Authenticated::Anonymous,
48+
Some(level) => {
49+
return Err(Status::permission_denied(format!(
50+
"invalid authorization level: {}",
51+
level
52+
)))
53+
}
54+
None => return Err(Status::invalid_argument("x-proxy-authorization not set")),
55+
};
56+
57+
Ok(auth)
58+
}
59+
60+
pub fn upgrade_grpc_request<T>(&self, req: &mut tonic::Request<T>) {
61+
let key = tonic::metadata::AsciiMetadataKey::from_static(GRPC_PROXY_AUTH_HEADER);
62+
63+
let auth = match self {
64+
Authenticated::Anonymous => "anonymous",
65+
Authenticated::Authorized(Authorized {
66+
permission: Permission::FullAccess,
67+
..
68+
}) => "full_access",
69+
Authenticated::Authorized(Authorized {
70+
permission: Permission::ReadOnly,
71+
..
72+
}) => "read_only",
73+
};
74+
75+
let value = tonic::metadata::AsciiMetadataValue::try_from(auth).unwrap();
76+
77+
req.metadata_mut().insert(key, value);
78+
}
79+
80+
pub fn is_namespace_authorized(&self, namespace: &NamespaceName) -> bool {
81+
match self {
82+
Authenticated::Anonymous => false,
83+
Authenticated::Authorized(Authorized {
84+
namespace: Some(ns),
85+
..
86+
}) => ns == namespace,
87+
// we threat the absence of a specific namespace has a permission to any namespace
88+
Authenticated::Authorized(Authorized {
89+
namespace: None, ..
90+
}) => true,
91+
}
92+
}
93+
}
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
use crate::auth::Permission;
2+
use crate::namespace::NamespaceName;
3+
4+
#[derive(Clone, Debug, PartialEq, Eq)]
5+
pub struct Authorized {
6+
pub namespace: Option<NamespaceName>,
7+
pub permission: Permission,
8+
}
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
pub(crate) static GRPC_AUTH_HEADER: &str = "x-authorization";
2+
pub(crate) static GRPC_PROXY_AUTH_HEADER: &str = "x-proxy-authorization";

libsql-server/src/auth/errors.rs

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
use tonic::Status;
2+
3+
#[derive(thiserror::Error, Debug, PartialEq)]
4+
pub enum AuthError {
5+
#[error("The `Authorization` HTTP header is required but was not specified")]
6+
HttpAuthHeaderMissing,
7+
#[error("The `Authorization` HTTP header has invalid value")]
8+
HttpAuthHeaderInvalid,
9+
#[error("The authentication scheme in the `Authorization` HTTP header is not supported")]
10+
HttpAuthHeaderUnsupportedScheme,
11+
#[error("The `Basic` HTTP authentication scheme is not allowed")]
12+
BasicNotAllowed,
13+
#[error("The `Basic` HTTP authentication credentials were rejected")]
14+
BasicRejected,
15+
#[error("Authentication is required but no JWT was specified")]
16+
JwtMissing,
17+
#[error("Authentication using a JWT is not allowed")]
18+
JwtNotAllowed,
19+
#[error("The JWT is invalid")]
20+
JwtInvalid,
21+
#[error("The JWT has expired")]
22+
JwtExpired,
23+
#[error("The JWT is immature (not valid yet)")]
24+
JwtImmature,
25+
#[error("Authentication failed")]
26+
Other,
27+
}
28+
29+
impl AuthError {
30+
pub fn code(&self) -> &'static str {
31+
match self {
32+
Self::HttpAuthHeaderMissing => "AUTH_HTTP_HEADER_MISSING",
33+
Self::HttpAuthHeaderInvalid => "AUTH_HTTP_HEADER_INVALID",
34+
Self::HttpAuthHeaderUnsupportedScheme => "AUTH_HTTP_HEADER_UNSUPPORTED_SCHEME",
35+
Self::BasicNotAllowed => "AUTH_BASIC_NOT_ALLOWED",
36+
Self::BasicRejected => "AUTH_BASIC_REJECTED",
37+
Self::JwtMissing => "AUTH_JWT_MISSING",
38+
Self::JwtNotAllowed => "AUTH_JWT_NOT_ALLOWED",
39+
Self::JwtInvalid => "AUTH_JWT_INVALID",
40+
Self::JwtExpired => "AUTH_JWT_EXPIRED",
41+
Self::JwtImmature => "AUTH_JWT_IMMATURE",
42+
Self::Other => "AUTH_FAILED",
43+
}
44+
}
45+
}
46+
47+
impl From<AuthError> for Status {
48+
fn from(e: AuthError) -> Self {
49+
Status::unauthenticated(format!("AuthError: {}", e))
50+
}
51+
}

libsql-server/src/auth/mod.rs

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
use std::sync::Arc;
2+
3+
pub mod authenticated;
4+
pub mod authorized;
5+
pub mod constants;
6+
pub mod errors;
7+
pub mod parsers;
8+
pub mod permission;
9+
pub mod user_auth_strategies;
10+
11+
pub use authenticated::Authenticated;
12+
pub use authorized::Authorized;
13+
pub use errors::AuthError;
14+
pub use parsers::{parse_http_auth_header, parse_http_basic_auth_arg, parse_jwt_key};
15+
pub use permission::Permission;
16+
pub use user_auth_strategies::{Disabled, HttpBasic, Jwt, UserAuthContext, UserAuthStrategy};
17+
18+
#[derive(Clone)]
19+
pub struct Auth {
20+
pub user_strategy: Arc<dyn UserAuthStrategy + Send + Sync>,
21+
}
22+
23+
impl Auth {
24+
pub fn new(user_strategy: impl UserAuthStrategy + Send + Sync + 'static) -> Self {
25+
Self {
26+
user_strategy: Arc::new(user_strategy),
27+
}
28+
}
29+
30+
pub fn authenticate(&self, context: UserAuthContext) -> Result<Authenticated, AuthError> {
31+
self.user_strategy.authenticate(context)
32+
}
33+
}

libsql-server/src/auth/parsers.rs

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
use crate::auth::{constants::GRPC_AUTH_HEADER, AuthError};
2+
3+
use anyhow::{bail, Context as _, Result};
4+
use axum::http::HeaderValue;
5+
use tonic::metadata::MetadataMap;
6+
7+
pub fn parse_http_basic_auth_arg(arg: &str) -> Result<Option<String>> {
8+
if arg == "always" {
9+
return Ok(None);
10+
}
11+
12+
let Some((scheme, param)) = arg.split_once(':') else {
13+
bail!("invalid HTTP auth config: {arg}")
14+
};
15+
16+
if scheme == "basic" {
17+
Ok(Some(param.into()))
18+
} else {
19+
bail!("unsupported HTTP auth scheme: {scheme:?}")
20+
}
21+
}
22+
23+
pub fn parse_jwt_key(data: &str) -> Result<jsonwebtoken::DecodingKey> {
24+
if data.starts_with("-----BEGIN PUBLIC KEY-----") {
25+
jsonwebtoken::DecodingKey::from_ed_pem(data.as_bytes())
26+
.context("Could not decode Ed25519 public key from PEM")
27+
} else if data.starts_with("-----BEGIN PRIVATE KEY-----") {
28+
bail!("Received a private key, but a public key is expected")
29+
} else if data.starts_with("-----BEGIN") {
30+
bail!("Key is in unsupported PEM format")
31+
} else {
32+
jsonwebtoken::DecodingKey::from_ed_components(data)
33+
.context("Could not decode Ed25519 public key from base64")
34+
}
35+
}
36+
37+
pub(crate) fn parse_grpc_auth_header(metadata: &MetadataMap) -> Option<HeaderValue> {
38+
metadata
39+
.get(GRPC_AUTH_HEADER)
40+
.map(|v| v.to_bytes().expect("Auth should always be ASCII"))
41+
.map(|v| HeaderValue::from_maybe_shared(v).expect("Should already be valid header"))
42+
}
43+
44+
pub fn parse_http_auth_header<'a>(
45+
expected_scheme: &str,
46+
auth_header: &'a Option<HeaderValue>,
47+
) -> Result<&'a str, AuthError> {
48+
let Some(header) = auth_header else {
49+
return Err(AuthError::HttpAuthHeaderMissing);
50+
};
51+
52+
let Ok(header) = header.to_str() else {
53+
return Err(AuthError::HttpAuthHeaderInvalid);
54+
};
55+
56+
let Some((scheme, param)) = header.split_once(' ') else {
57+
return Err(AuthError::HttpAuthHeaderInvalid);
58+
};
59+
60+
if !scheme.eq_ignore_ascii_case(expected_scheme) {
61+
return Err(AuthError::HttpAuthHeaderUnsupportedScheme);
62+
}
63+
64+
Ok(param)
65+
}
66+
67+
#[cfg(test)]
68+
mod tests {
69+
use axum::http::HeaderValue;
70+
use hyper::header::AUTHORIZATION;
71+
72+
use crate::auth::{parse_http_auth_header, AuthError};
73+
74+
#[test]
75+
fn parse_http_auth_header_returns_auth_header_param_when_valid() {
76+
assert_eq!(
77+
parse_http_auth_header("basic", &HeaderValue::from_str("Basic abc").ok()).unwrap(),
78+
"abc"
79+
)
80+
}
81+
82+
#[test]
83+
fn parse_http_auth_header_errors_when_auth_header_missing() {
84+
assert_eq!(
85+
parse_http_auth_header("basic", &None).unwrap_err(),
86+
AuthError::HttpAuthHeaderMissing
87+
)
88+
}
89+
90+
#[test]
91+
fn parse_http_auth_header_errors_when_auth_header_cannot_be_converted_to_str() {
92+
assert_eq!(
93+
parse_http_auth_header("basic", &Some(HeaderValue::from_name(AUTHORIZATION)))
94+
.unwrap_err(),
95+
AuthError::HttpAuthHeaderInvalid
96+
)
97+
}
98+
99+
#[test]
100+
fn parse_http_auth_header_errors_when_auth_header_invalid_format() {
101+
assert_eq!(
102+
parse_http_auth_header("basic", &HeaderValue::from_str("invalid").ok()).unwrap_err(),
103+
AuthError::HttpAuthHeaderInvalid
104+
)
105+
}
106+
107+
#[test]
108+
fn parse_http_auth_header_errors_when_auth_header_is_unsupported_scheme() {
109+
assert_eq!(
110+
parse_http_auth_header("basic", &HeaderValue::from_str("Bearer abc").ok()).unwrap_err(),
111+
AuthError::HttpAuthHeaderUnsupportedScheme
112+
)
113+
}
114+
}
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
#[non_exhaustive]
2+
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
3+
pub enum Permission {
4+
FullAccess,
5+
ReadOnly,
6+
}
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
use super::{UserAuthContext, UserAuthStrategy};
2+
use crate::auth::{AuthError, Authenticated, Authorized, Permission};
3+
4+
pub struct Disabled {}
5+
6+
impl UserAuthStrategy for Disabled {
7+
fn authenticate(&self, _context: UserAuthContext) -> Result<Authenticated, AuthError> {
8+
tracing::info!("executing disabled auth");
9+
10+
Ok(Authenticated::Authorized(Authorized {
11+
namespace: None,
12+
permission: Permission::FullAccess,
13+
}))
14+
}
15+
}
16+
17+
impl Disabled {
18+
pub fn new() -> Self {
19+
Self {}
20+
}
21+
}
22+
23+
#[cfg(test)]
24+
mod tests {
25+
use crate::namespace::NamespaceName;
26+
27+
use super::*;
28+
29+
#[test]
30+
fn authenticates() {
31+
let strategy = Disabled::new();
32+
let context = UserAuthContext {
33+
namespace: NamespaceName::default(),
34+
namespace_credential: None,
35+
user_credential: None,
36+
};
37+
38+
assert_eq!(
39+
strategy.authenticate(context).unwrap(),
40+
Authenticated::Authorized(Authorized {
41+
namespace: None,
42+
permission: Permission::FullAccess,
43+
})
44+
)
45+
}
46+
}

0 commit comments

Comments
 (0)