Skip to content

Commit 1efce7d

Browse files
grtlrjleibs
andauthored
Basic JWT-based authentication and authorization (#8627)
### What This adds a new `re_auth` crate with the following features: * JWT-based symmetric authorization and authentication (basic read/write modes). * Rerun-ified abstraction over `jwt-simple` so that we can swap it out. * Helpers to convert a `SecretKey` from/to `base64` to be used with `redap-cli`. * `tonic::Interceptor`s for both client and server side middleware with an `authorization: Bearer <token>` header. Here is what a `SecretKey` (`HS256`) looks like in `base64`: ``` pBiQ9NVDj1elVjATgyL5EYri/9paHwvz78lsx7QCq9E= ``` We can use that to generate a basic token: ``` eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJyZWRhcC1jbGkiLCJzdWIiOiJqb2NoZW5AcmVydW4uaW8iLCJhdWQiOiJyZWRhcCIsImV4cCI6MTczNjg3MjUzNCwiaWF0IjoxNzM2ODQ0NTM0fQ.QHI99cXIi4VjhZKBJmLb13NynOjvmUWuxy63CUwiBlA ``` Which you can verify yourself via [www.jwt.io](https://jwt.io/#debugger-io?token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpYXQiOjE3MzY3ODYwMjIsImV4cCI6MTczNjgxNDAyMiwibmJmIjoxNzM2Nzg2MDIyLCJpc3MiOiJyZWRhcC1jbGkiLCJzdWIiOiJqb2NoZW5AcmVydW4uaW8iLCJhdWQiOiJyZWRhcCJ9.qiClC8YhTszvYFKk6HgL-Gp9W_lDmfiZGyYB43O3Z58). <img width="1221" alt="image" src="https://github.com/user-attachments/assets/e6c0236e-89cb-4e73-9ead-7e0d4ed7e6a0" /> --------- Co-authored-by: Jeremy Leibs <[email protected]>
1 parent 52c0f9a commit 1efce7d

File tree

15 files changed

+512
-52
lines changed

15 files changed

+512
-52
lines changed

ARCHITECTURE.md

Lines changed: 53 additions & 52 deletions
Large diffs are not rendered by default.

Cargo.lock

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3754,6 +3754,19 @@ dependencies = [
37543754
"wasm-bindgen",
37553755
]
37563756

3757+
[[package]]
3758+
name = "jsonwebtoken"
3759+
version = "9.3.0"
3760+
source = "registry+https://github.com/rust-lang/crates.io-index"
3761+
checksum = "b9ae10193d25051e74945f1ea2d0b42e03cc3b890f7e4cc5faa44997d808193f"
3762+
dependencies = [
3763+
"base64 0.21.7",
3764+
"js-sys",
3765+
"ring",
3766+
"serde",
3767+
"serde_json",
3768+
]
3769+
37573770
[[package]]
37583771
name = "khronos-egl"
37593772
version = "6.0.0"
@@ -5619,6 +5632,19 @@ dependencies = [
56195632
"re_tracing",
56205633
]
56215634

5635+
[[package]]
5636+
name = "re_auth"
5637+
version = "0.22.0-alpha.1+dev"
5638+
dependencies = [
5639+
"base64 0.22.1",
5640+
"jsonwebtoken",
5641+
"rand",
5642+
"re_log",
5643+
"serde",
5644+
"thiserror 1.0.65",
5645+
"tonic",
5646+
]
5647+
56225648
[[package]]
56235649
name = "re_blueprint_tree"
56245650
version = "0.22.0-alpha.1+dev"

Cargo.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,7 @@ arrow = { version = "53.1", default-features = false }
157157
arrow2 = { package = "re_arrow2", version = "0.18", features = ["arrow"] }
158158
async-executor = "1.0"
159159
backtrace = "0.3"
160+
base64 = "0.22"
160161
bincode = "1.3"
161162
bit-vec = "0.8"
162163
bitflags = { version = "2.4", features = ["bytemuck"] }
@@ -204,6 +205,7 @@ infer = "0.16" # infer MIME type by checking the magic number signaturefer MIME
204205
insta = "1.23"
205206
itertools = "0.13"
206207
js-sys = "0.3"
208+
jsonwebtoken = { version = "9", default-features = false }
207209
libc = "0.2"
208210
linked-hash-map = { version = "0.5", default-features = false }
209211
log = "0.4"

clippy.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,7 @@ doc-valid-idents = [
9696
"MiMalloc",
9797
"NaN",
9898
"OBJ",
99+
"OpenID",
99100
"OpenGL",
100101
"PyPI",
101102
"sRGB",

crates/utils/re_auth/Cargo.toml

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
[package]
2+
name = "re_auth"
3+
description = "Authentication helpers for Rerun"
4+
authors.workspace = true
5+
edition.workspace = true
6+
homepage.workspace = true
7+
include.workspace = true
8+
license.workspace = true
9+
repository.workspace = true
10+
rust-version.workspace = true
11+
version.workspace = true
12+
13+
[lints]
14+
workspace = true
15+
16+
[dependencies]
17+
re_log.workspace = true
18+
19+
base64.workspace = true
20+
jsonwebtoken.workspace = true
21+
rand.workspace = true
22+
serde.workspace = true
23+
thiserror.workspace = true
24+
tonic.workspace = true
25+
26+
[dev-dependencies]
27+
rand = { workspace = true, features = ["std", "std_rng"] }

crates/utils/re_auth/README.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
# re_auth
2+
3+
Part of the [`rerun`](https://github.com/rerun-io/rerun) family of crates.
4+
5+
[![Latest version](https://img.shields.io/crates/v/re_auth.svg)](https://crates.io/crates/re_auth?speculative-link)
6+
[![Documentation](https://docs.rs/re_auth/badge.svg)](https://docs.rs/re_auth?speculative-link)
7+
![MIT](https://img.shields.io/badge/license-MIT-blue.svg)
8+
![Apache](https://img.shields.io/badge/license-Apache-blue.svg)
9+
10+
Authentication and authorization helpers for Rerun, using JSON Web Tokens (JWT).

crates/utils/re_auth/src/error.rs

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
/// Handles errors for the `re_auth` crate.
2+
#[derive(Debug, thiserror::Error)]
3+
pub enum Error {
4+
#[error("transparent")]
5+
Jwt(#[from] jsonwebtoken::errors::Error),
6+
7+
#[error("transparent")]
8+
Base64Decode(#[from] base64::DecodeError),
9+
10+
#[error("transparent")]
11+
SystemTime(#[from] std::time::SystemTimeError),
12+
13+
#[error("failed to parse token")]
14+
MalformedToken,
15+
}

crates/utils/re_auth/src/lib.rs

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
//! Basic authentication helpers for Rerun.
2+
//!
3+
//! Currently, this crate provides a simple [`Jwt`]-based authentication scheme on
4+
//! top of a rudimentary [`RedapProvider`] that uses a symmetric key to _both_
5+
//! generate and sign tokens.
6+
//!
7+
//! **Warning!** This approach should only be seen as a stop-gap until we have
8+
//! integration of _real_ identity-providers, most likely based on OpenID Connect.
9+
10+
pub use error::Error;
11+
pub use provider::{Claims, RedapProvider, VerificationOptions};
12+
pub use service::*;
13+
pub use token::{Jwt, TokenError};
14+
15+
mod error;
16+
mod provider;
17+
mod service;
18+
mod token;

crates/utils/re_auth/src/provider.rs

Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
1+
use std::time::Duration;
2+
3+
use base64::{engine::general_purpose, Engine as _};
4+
use jsonwebtoken::{decode, encode, Algorithm, DecodingKey, EncodingKey, Header, Validation};
5+
6+
use crate::{Error, Jwt};
7+
8+
/// Identifies who should be the consumer of a token. In our case, this is the Rerun storage node.
9+
const AUDIENCE: &str = "redap";
10+
11+
/// A secret key that is used to generate and verify tokens.
12+
///
13+
/// This represents a symmetric authentication scheme, which means that the
14+
/// same key is used to both sign and verify the token.
15+
/// In the future, we will need to support asymmetric schemes too.
16+
///
17+
/// The key is stored unencrypted in memory.
18+
#[derive(Clone, PartialEq, Eq)]
19+
#[repr(transparent)]
20+
pub struct RedapProvider {
21+
secret_key: Vec<u8>,
22+
}
23+
24+
impl std::fmt::Debug for RedapProvider {
25+
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
26+
f.debug_struct("RedapProvider")
27+
.field("secret_key", &"********")
28+
.finish()
29+
}
30+
}
31+
32+
#[derive(Debug, serde::Serialize, serde::Deserialize)]
33+
pub struct Claims {
34+
/// The issuer of the token.
35+
///
36+
/// Could be an identity provider or the storage node directly.
37+
pub iss: String,
38+
39+
/// The subject (user) of the token.
40+
pub sub: String,
41+
42+
/// The audience of the token, i.e. who should consume it.
43+
///
44+
/// Most of the time this will be the storage node.
45+
pub aud: String,
46+
47+
/// Expiry time of the token.
48+
pub exp: u64,
49+
50+
/// Issued at time of the token.
51+
pub iat: u64,
52+
}
53+
54+
#[derive(Debug, Clone)]
55+
pub struct VerificationOptions {
56+
leeway: Option<Duration>,
57+
}
58+
59+
impl VerificationOptions {
60+
#[inline]
61+
pub fn with_leeway(mut self, leeway: Option<Duration>) -> Self {
62+
self.leeway = leeway;
63+
self
64+
}
65+
66+
#[inline]
67+
pub fn without_leeway(mut self) -> Self {
68+
self.leeway = None;
69+
self
70+
}
71+
}
72+
73+
impl Default for VerificationOptions {
74+
fn default() -> Self {
75+
Self {
76+
// 5 minutes to prevent clock skew
77+
leeway: Some(Duration::from_secs(5 * 60)),
78+
}
79+
}
80+
}
81+
82+
impl From<VerificationOptions> for Validation {
83+
fn from(options: VerificationOptions) -> Self {
84+
let mut validation = Self::new(Algorithm::HS256);
85+
validation.set_audience(&[AUDIENCE.to_owned()]);
86+
validation.set_required_spec_claims(&["exp", "sub", "aud", "iss"]);
87+
validation.leeway = options.leeway.map_or(0, |leeway| leeway.as_secs());
88+
validation
89+
}
90+
}
91+
92+
// Generate a random secret key of specified length
93+
fn generate_secret_key(mut rng: impl rand::Rng, length: usize) -> Vec<u8> {
94+
(0..length).map(|_| rng.gen::<u8>()).collect()
95+
}
96+
97+
impl RedapProvider {
98+
/// Generates a new secret key.
99+
pub fn generate(rng: impl rand::Rng) -> Self {
100+
// 32 bytes or 256 bits
101+
let secret_key = generate_secret_key(rng, 32);
102+
103+
debug_assert_eq!(
104+
secret_key.len() * size_of::<u8>() * 8,
105+
256,
106+
"The resulting secret should be 256 bits."
107+
);
108+
109+
Self { secret_key }
110+
}
111+
112+
/// Decodes a [`base64`] encoded secret key.
113+
pub fn from_base64(base64: impl AsRef<str>) -> Result<Self, Error> {
114+
let secret_key = general_purpose::STANDARD.decode(base64.as_ref())?;
115+
Ok(Self { secret_key })
116+
}
117+
118+
/// Encodes the secret key as a [`base64`] string.
119+
pub fn to_base64(&self) -> String {
120+
general_purpose::STANDARD.encode(&self.secret_key)
121+
}
122+
123+
/// Generates a new JWT token that is valid for the given duration.
124+
///
125+
/// It is important to note that the token is not encrypted, but merely
126+
/// signed by the [`RedapProvider`]. This means that its contents are readable
127+
/// by everyone.
128+
///
129+
/// If `duration` is `None`, the token will be valid forever. `scope` can be
130+
/// used to restrict the token to a specific context.
131+
pub fn token(
132+
&self,
133+
duration: Duration,
134+
issuer: impl Into<String>,
135+
subject: impl Into<String>,
136+
) -> Result<Jwt, Error> {
137+
let now = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH)?;
138+
139+
let claims = Claims {
140+
iss: issuer.into(),
141+
sub: subject.into(),
142+
aud: AUDIENCE.to_owned(),
143+
exp: (now + duration).as_secs(),
144+
iat: now.as_secs(),
145+
};
146+
147+
let token = encode(
148+
&Header::default(),
149+
&claims,
150+
&EncodingKey::from_secret(&self.secret_key),
151+
)?;
152+
153+
Ok(Jwt(token))
154+
}
155+
156+
/// Checks if a provided `token` is valid for a given `scope`.
157+
pub fn verify(&self, token: &Jwt, options: VerificationOptions) -> Result<Claims, Error> {
158+
let validation = options.into();
159+
160+
let token_data = decode::<Claims>(
161+
&token.0,
162+
&DecodingKey::from_secret(&self.secret_key),
163+
&validation,
164+
)?;
165+
166+
Ok(token_data.claims)
167+
}
168+
}
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
use re_log::error;
2+
3+
use tonic::{metadata::errors::InvalidMetadataValue, service::Interceptor, Request, Status};
4+
5+
use crate::Jwt;
6+
7+
use super::{AUTHORIZATION_KEY, TOKEN_PREFIX};
8+
9+
#[derive(Default)]
10+
pub struct AuthDecorator {
11+
jwt: Option<Jwt>,
12+
}
13+
14+
impl AuthDecorator {
15+
pub fn new(jwt: Option<Jwt>) -> Self {
16+
Self { jwt }
17+
}
18+
}
19+
20+
impl Interceptor for AuthDecorator {
21+
fn call(&mut self, req: Request<()>) -> Result<Request<()>, Status> {
22+
if let Some(jwt) = self.jwt.as_ref() {
23+
let token = format!("{TOKEN_PREFIX}{}", jwt.0).parse().map_err(
24+
|err: InvalidMetadataValue| {
25+
error!("malformed token: {}", err.to_string());
26+
Status::invalid_argument("malformed token")
27+
},
28+
)?;
29+
30+
let mut req = req;
31+
req.metadata_mut().insert(AUTHORIZATION_KEY, token);
32+
33+
Ok(req)
34+
} else {
35+
Ok(req)
36+
}
37+
}
38+
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
//! This module contains auth middleware for [`tonic`] services.
2+
3+
pub mod client;
4+
pub mod server;
5+
6+
/// The metadata key used in the metadata of the gRPC request to store the token.
7+
const AUTHORIZATION_KEY: &str = "authorization";
8+
9+
/// The prefix for the token in the metadata of the gRPC request.
10+
const TOKEN_PREFIX: &str = "Bearer ";

0 commit comments

Comments
 (0)