Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Basic JWT-based authentication and authorization #8627

Open
wants to merge 25 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 19 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
105 changes: 53 additions & 52 deletions ARCHITECTURE.md

Large diffs are not rendered by default.

32 changes: 29 additions & 3 deletions Cargo.lock
Original file line number Diff line number Diff line change
Expand Up @@ -3739,6 +3739,19 @@ dependencies = [
"wasm-bindgen",
]

[[package]]
name = "jsonwebtoken"
version = "9.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9ae10193d25051e74945f1ea2d0b42e03cc3b890f7e4cc5faa44997d808193f"
dependencies = [
"base64 0.21.7",
"js-sys",
"ring",
"serde",
"serde_json",
]

[[package]]
name = "khronos-egl"
version = "6.0.0"
Expand Down Expand Up @@ -3885,7 +3898,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4979f22fdb869068da03c9f7528f8297c6fd2606bc3a4affe42e6a823fdb8da4"
dependencies = [
"cfg-if",
"windows-targets 0.48.5",
"windows-targets 0.52.6",
]

[[package]]
Expand Down Expand Up @@ -5203,7 +5216,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c1318b19085f08681016926435853bbf7858f9c082d0999b80550ff5d9abe15"
dependencies = [
"bytes",
"heck 0.4.1",
"heck 0.5.0",
"itertools 0.13.0",
"log",
"multimap",
Expand Down Expand Up @@ -5593,6 +5606,19 @@ dependencies = [
"simdutf8",
]

[[package]]
name = "re_auth"
version = "0.22.0-alpha.1+dev"
dependencies = [
"base64 0.22.1",
"jsonwebtoken",
"rand",
"re_log",
"serde",
"thiserror 1.0.65",
"tonic",
]

[[package]]
name = "re_blueprint_tree"
version = "0.22.0-alpha.1+dev"
Expand Down Expand Up @@ -9846,7 +9872,7 @@ version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf221c93e13a30d793f7645a0e7762c55d169dbb0a49671918a2319d289b10bb"
dependencies = [
"windows-sys 0.48.0",
"windows-sys 0.59.0",
]

[[package]]
Expand Down
2 changes: 2 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,7 @@ arrow = { version = "53.1", default-features = false }
arrow2 = { package = "re_arrow2", version = "0.18" }
async-executor = "1.0"
backtrace = "0.3"
base64 = "0.22"
bincode = "1.3"
bit-vec = "0.8"
bitflags = { version = "2.4", features = ["bytemuck"] }
Expand Down Expand Up @@ -202,6 +203,7 @@ infer = "0.16" # infer MIME type by checking the magic number signaturefer MIME
insta = "1.23"
itertools = "0.13"
js-sys = "0.3"
jsonwebtoken = { version = "9", default-features = false }
libc = "0.2"
linked-hash-map = { version = "0.5", default-features = false }
log = "0.4"
Expand Down
27 changes: 27 additions & 0 deletions crates/utils/re_auth/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
[package]
name = "re_auth"
description = "Authentication helpers for Rerun"
authors.workspace = true
edition.workspace = true
homepage.workspace = true
include.workspace = true
license.workspace = true
repository.workspace = true
rust-version.workspace = true
version.workspace = true

[lints]
workspace = true

[dependencies]
re_log.workspace = true

base64.workspace = true
jsonwebtoken.workspace = true
rand.workspace = true
serde.workspace = true
thiserror.workspace = true
tonic.workspace = true

[dev-dependencies]
rand = { workspace = true, features = ["std", "std_rng"] }
10 changes: 10 additions & 0 deletions crates/utils/re_auth/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
# re_auth

Part of the [`rerun`](https://github.com/rerun-io/rerun) family of crates.

[![Latest version](https://img.shields.io/crates/v/re_auth.svg)](https://crates.io/crates/re_auth?speculative-link)
[![Documentation](https://docs.rs/re_auth/badge.svg)](https://docs.rs/re_auth?speculative-link)
![MIT](https://img.shields.io/badge/license-MIT-blue.svg)
![Apache](https://img.shields.io/badge/license-Apache-blue.svg)

Authentication and authorization helpers for Rerun, using JSON Web Tokens (JWT).
18 changes: 18 additions & 0 deletions crates/utils/re_auth/src/error.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
/// Handles errors for the `re_auth` crate.
#[derive(Debug, thiserror::Error)]
pub enum Error {
#[error("transparent")]
Jwt(#[from] jsonwebtoken::errors::Error),

#[error("transparent")]
Base64Decode(#[from] base64::DecodeError),

#[error("transparent")]
SystemTime(#[from] std::time::SystemTimeError),

#[error("failed to parse token")]
MalformedToken,

#[error("invalid permission: expected `{expected}` but got `{actual}`")]
InvalidPermission { expected: String, actual: String },
}
9 changes: 9 additions & 0 deletions crates/utils/re_auth/src/lib.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
pub use error::Error;
pub use provider::{Claims, RedapProvider, VerificationOptions};
pub use service::*;
pub use token::{Jwt, TokenError};

mod error;
mod provider;
mod service;
mod token;
168 changes: 168 additions & 0 deletions crates/utils/re_auth/src/provider.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
use std::time::Duration;

use base64::{engine::general_purpose, Engine as _};
use jsonwebtoken::{decode, encode, Algorithm, DecodingKey, EncodingKey, Header, Validation};

use crate::{Error, Jwt};

/// Identifies who should be the consumer of a token. In our case, this is the Rerun storage node.
const AUDIENCE: &str = "redap";

/// A secret key that is used to generate and verify tokens.
///
/// This represents a symmetric authentication scheme, which means that the
/// same key is used to both sign and verify the token.
/// In the future, we will need to support asymmetric schemes too.
///
/// The key is stored unencrypted in memory.
#[derive(Clone, PartialEq, Eq)]
#[repr(transparent)]
pub struct RedapProvider {
secret_key: Vec<u8>,
}

impl std::fmt::Debug for RedapProvider {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("RedapProvider")
.field("secret_key", &"********")
.finish()
}
}

#[derive(Debug, serde::Serialize, serde::Deserialize)]
pub struct Claims {
/// The issuer of the token.
///
/// Could be an identity provider or the storage node directly.
pub iss: String,

/// The subject (user) of the token.
pub sub: String,

/// The audience of the token, i.e. who should consume it.
///
/// Most of the time this will be the storage node.
pub aud: String,

/// Expiry time of the token.
pub exp: u64,

/// Issued at time of the token.
pub iat: u64,
}

#[derive(Debug, Clone)]
pub struct VerificationOptions {
leeway: Option<Duration>,
}

impl VerificationOptions {
#[inline]
pub fn with_leeway(mut self, leeway: Option<Duration>) -> Self {
self.leeway = leeway;
self
}

#[inline]
pub fn without_leeway(mut self) -> Self {
self.leeway = None;
self
}
}

impl Default for VerificationOptions {
fn default() -> Self {
Self {
// 5 minutes to prevent clock skew
leeway: Some(Duration::from_secs(5 * 60)),
}
}
}

impl From<VerificationOptions> for Validation {
fn from(options: VerificationOptions) -> Self {
let mut validation = Self::new(Algorithm::HS256);
validation.set_audience(&[AUDIENCE.to_owned()]);
validation.set_required_spec_claims(&["exp", "sub", "aud", "iss"]);
validation.leeway = options.leeway.map_or(0, |leeway| leeway.as_secs());
validation
}
}

// Generate a random secret key of specified length
fn generate_secret_key(mut rng: impl rand::Rng, length: usize) -> Vec<u8> {
(0..length).map(|_| rng.gen::<u8>()).collect()
}

impl RedapProvider {
/// Generates a new secret key.
pub fn generate(rng: impl rand::Rng) -> Self {
// 32 bytes or 256 bits
let secret_key = generate_secret_key(rng, 32);

debug_assert_eq!(
secret_key.len() * size_of::<u8>() * 8,
256,
"The resulting secret should be 256 bits."
);

Self { secret_key }
}

/// Decodes a [`base64`] encoded secret key.
pub fn from_base64(base64: impl AsRef<str>) -> Result<Self, Error> {
let secret_key = general_purpose::STANDARD.decode(base64.as_ref())?;
Ok(Self { secret_key })
}

/// Encodes the secret key as a [`base64`] string.
pub fn to_base64(&self) -> String {
general_purpose::STANDARD.encode(&self.secret_key)
}

/// Generates a new JWT token that is valid for the given duration.
///
/// It is important to note that the token is not encrypted, but merely
/// signed by the [`RedapProvider`]. This means that its contents are readable
/// by everyone.
///
/// If `duration` is `None`, the token will be valid forever. `scope` can be
/// used to restrict the token to a specific context.
pub fn token(
&self,
duration: Duration,
issuer: impl Into<String>,
subject: impl Into<String>,
) -> Result<Jwt, Error> {
let now = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH)?;

let claims = Claims {
iss: issuer.into(),
sub: subject.into(),
aud: AUDIENCE.to_owned(),
exp: (now + duration).as_secs(),
iat: now.as_secs(),
};

let token = encode(
&Header::default(),
&claims,
&EncodingKey::from_secret(&self.secret_key),
)?;

Ok(Jwt(token))
}

/// Checks if a provided `token` is valid for a given `scope`.
pub fn verify(&self, token: &Jwt, options: VerificationOptions) -> Result<Claims, Error> {
let validation = options.into();

let token_data = decode::<Claims>(
&token.0,
&DecodingKey::from_secret(&self.secret_key),
&validation,
)?;

Ok(token_data.claims)
}
}
38 changes: 38 additions & 0 deletions crates/utils/re_auth/src/service/client.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
use re_log::error;

use tonic::{metadata::errors::InvalidMetadataValue, service::Interceptor, Request, Status};

use crate::Jwt;

use super::{AUTHORIZATION_KEY, TOKEN_PREFIX};

#[derive(Default)]
pub struct AuthDecorator {
jwt: Option<Jwt>,
}

impl AuthDecorator {
pub fn new(jwt: Option<Jwt>) -> Self {
Self { jwt }
}
}

impl Interceptor for AuthDecorator {
fn call(&mut self, req: Request<()>) -> Result<Request<()>, Status> {
if let Some(jwt) = self.jwt.as_ref() {
let token = format!("{TOKEN_PREFIX}{}", jwt.0).parse().map_err(
|err: InvalidMetadataValue| {
error!("malformed token: {}", err.to_string());
Status::invalid_argument("malformed token")
},
)?;

let mut req = req;
req.metadata_mut().insert(AUTHORIZATION_KEY, token);

Ok(req)
} else {
Ok(req)
}
}
}
10 changes: 10 additions & 0 deletions crates/utils/re_auth/src/service/mod.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
//! This module contains auth middleware for [`tonic`] services.

pub mod client;
pub mod server;

/// The metadata key used in the metadata of the gRPC request to store the token.
const AUTHORIZATION_KEY: &str = "authorization";

/// The prefix for the token in the metadata of the gRPC request.
const TOKEN_PREFIX: &str = "Bearer ";
Loading
Loading