Skip to content

Commit 0b827bf

Browse files
committed
Add OIDC authentication and provider support
Introduce OIDC support across the authentication stack: add a new kalamdb-oidc crate and wire it into the workspace and Cargo files, enable reqwest "form" feature, and add serde/urlencoded/sha2 deps where needed. Add a reusable OAuthProvider type and provider-aware username helpers in kalamdb-commons, plus provider detection/prefixing and serde support. Update jwt_auth to re-export core JWT/OIDC types and to treat internal (HS256) vs external (RS/ES/PS) tokens differently; move algorithm/issuer extraction and internal-issuer checks into jwt_auth. Extend JwtConfig with an async per-issuer OIDC validator registry (lazy discovery + cached JWKS) and route bearer token validation in bearer.rs to either internal HS256 validation or external OIDC validator, mapping validation errors to AuthError. Add conversion from kalamdb-oidc errors to AuthError, auto-provisioning flow for OIDC users, small logging level tweak, and supporting docs/examples/docker/keycloak changes and tests.
1 parent b7a7f47 commit 0b827bf

51 files changed

Lines changed: 3113 additions & 826 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

Cargo.lock

Lines changed: 19 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ members = [
1717
"backend/crates/kalamdb-store",
1818
"backend/crates/kalamdb-api",
1919
"backend/crates/kalamdb-auth",
20+
"backend/crates/kalamdb-oidc",
2021
"backend/crates/kalamdb-raft",
2122
"backend",
2223
"link",
@@ -51,7 +52,7 @@ tokio-stream = { version = "0.1", features = ["net"] }
5152

5253
# HTTP client (with HTTP/2 support) - using rustls-tls for cross-compilation compatibility
5354
# Note: native-tls requires OpenSSL which is difficult to cross-compile for aarch64
54-
reqwest = { version = "0.13.1", default-features = false, features = ["json", "rustls", "http2", "multipart", "stream"] }
55+
reqwest = { version = "0.13.1", default-features = false, features = ["json", "rustls", "http2", "multipart", "stream", "form"] }
5556

5657
# WebSocket
5758
tokio-tungstenite = { version = "0.28.0", features = ["rustls-tls-webpki-roots"] }

backend/crates/kalamdb-api/src/handlers/ws/events/unsubscribe.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
55
use kalamdb_commons::models::LiveQueryId;
66
use kalamdb_core::live::{LiveQueryManager, SharedConnectionState};
7-
use log::{error, info};
7+
use log::{error, info, debug};
88
use std::sync::Arc;
99

1010
use crate::limiter::RateLimiter;
@@ -40,6 +40,6 @@ pub async fn handle_unsubscribe(
4040
// Update rate limiter
4141
rate_limiter.decrement_subscription(&user_id);
4242

43-
info!("Unsubscribed: {}", subscription_id);
43+
debug!("Unsubscribed: {}", subscription_id);
4444
Ok(())
4545
}

backend/crates/kalamdb-auth/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ cookie = { workspace = true }
3131
# Internal dependencies
3232
kalamdb-commons = { path = "../kalamdb-commons" }
3333
kalamdb-configs = { path = "../kalamdb-configs" }
34+
kalamdb-oidc = { path = "../kalamdb-oidc" }
3435
kalamdb-session = { path = "../kalamdb-session" }
3536
kalamdb-store = { path = "../kalamdb-store" }
3637
kalamdb-sql = { path = "../kalamdb-sql" }

backend/crates/kalamdb-auth/src/errors/error.rs

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,3 +77,26 @@ pub enum AuthError {
7777

7878
/// Result type for authentication operations
7979
pub type AuthResult<T> = Result<T, AuthError>;
80+
81+
/// Convert an `OidcError` into an `AuthError`.
82+
///
83+
/// Used by the `?` operator in `bearer.rs` when calling re-exported JWT utility
84+
/// functions (`extract_issuer_unverified`, `extract_algorithm_unverified`) that
85+
/// now originate from `kalamdb-oidc`.
86+
impl From<kalamdb_oidc::OidcError> for AuthError {
87+
fn from(e: kalamdb_oidc::OidcError) -> Self {
88+
match e {
89+
kalamdb_oidc::OidcError::JwtValidationFailed(ref msg)
90+
if msg.contains("expired") =>
91+
{
92+
AuthError::TokenExpired
93+
}
94+
kalamdb_oidc::OidcError::JwtValidationFailed(ref msg)
95+
if msg.contains("signature") =>
96+
{
97+
AuthError::InvalidSignature
98+
}
99+
_ => AuthError::MalformedAuthorization(e.to_string()),
100+
}
101+
}
102+
}

backend/crates/kalamdb-auth/src/providers/jwt_auth.rs

Lines changed: 27 additions & 138 deletions
Original file line numberDiff line numberDiff line change
@@ -1,107 +1,32 @@
11
// JWT authentication and validation module
2+
//
3+
// This module handles:
4+
// - HS256 internal token generation, signing, and validation
5+
// - Internal issuer trust verification (`KALAMDB_ISSUER`, `is_internal_issuer`, `verify_issuer`)
6+
//
7+
// The following types are defined in `kalamdb-oidc` and re-exported here so
8+
// existing call-sites continue to work unchanged:
9+
// `JwtClaims`, `TokenType`, `DEFAULT_JWT_EXPIRY_HOURS`
10+
// `extract_issuer_unverified`, `extract_algorithm_unverified`
11+
//
12+
// External OIDC token validation (RS256/ES256 via JWKS) is handled by
13+
// `kalamdb-oidc` and orchestrated in `bearer.rs`.
214

315
use crate::errors::error::{AuthError, AuthResult};
416
use jsonwebtoken::errors::ErrorKind;
517
use jsonwebtoken::{
618
decode, decode_header, encode, Algorithm, DecodingKey, EncodingKey, Header, Validation,
719
};
820
use kalamdb_commons::{Role, UserId, UserName};
9-
use serde::{Deserialize, Serialize};
1021

11-
/// Default JWT expiration time in hours
12-
pub const DEFAULT_JWT_EXPIRY_HOURS: i64 = 24;
22+
// ── Types and utilities that live in kalamdb-oidc ───────────────────────────
23+
// Re-exported here so callers using `jwt_auth::JwtClaims` etc. need no changes.
24+
pub use kalamdb_oidc::{extract_algorithm_unverified, extract_issuer_unverified};
25+
pub use kalamdb_oidc::{JwtClaims, TokenType, DEFAULT_JWT_EXPIRY_HOURS};
1326

14-
/// Default issuer for KalamDB tokens
27+
/// Default issuer for KalamDB-issued tokens.
1528
pub const KALAMDB_ISSUER: &str = "kalamdb";
1629

17-
/// Token type for distinguishing access from refresh tokens.
18-
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
19-
#[serde(rename_all = "lowercase")]
20-
pub enum TokenType {
21-
Access,
22-
Refresh,
23-
}
24-
25-
impl std::fmt::Display for TokenType {
26-
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
27-
match self {
28-
TokenType::Access => write!(f, "access"),
29-
TokenType::Refresh => write!(f, "refresh"),
30-
}
31-
}
32-
}
33-
34-
/// JWT claims structure for KalamDB tokens.
35-
///
36-
/// Standard JWT claims plus custom KalamDB-specific fields.
37-
#[derive(Debug, Clone, Serialize, Deserialize)]
38-
pub struct JwtClaims {
39-
/// Subject (user ID)
40-
pub sub: String,
41-
/// Issuer
42-
pub iss: String,
43-
/// Expiration time (Unix timestamp)
44-
pub exp: usize,
45-
/// Issued at (Unix timestamp)
46-
pub iat: usize,
47-
/// Username (custom claim). Also maps provider claim `preferred_username`.
48-
#[serde(alias = "preferred_username")]
49-
pub username: Option<UserName>,
50-
/// Email (custom claim)
51-
pub email: Option<String>,
52-
/// Role (custom claim)
53-
pub role: Option<Role>,
54-
/// Token type: "access" or "refresh"
55-
/// Optional for backward compatibility with tokens issued before this field existed.
56-
#[serde(default, skip_serializing_if = "Option::is_none")]
57-
pub token_type: Option<TokenType>,
58-
}
59-
60-
impl JwtClaims {
61-
/// Create new JWT claims for a user (defaults to access token).
62-
///
63-
/// # Arguments
64-
/// * `user_id` - User's unique identifier
65-
/// * `username` - Username
66-
/// * `role` - User's role
67-
/// * `email` - Optional email address
68-
/// * `expiry_hours` - Token expiration in hours (defaults to DEFAULT_JWT_EXPIRY_HOURS)
69-
pub fn new(
70-
user_id: &UserId,
71-
username: &UserName,
72-
role: &Role,
73-
email: Option<&str>,
74-
expiry_hours: Option<i64>,
75-
) -> Self {
76-
Self::with_token_type(user_id, username, role, email, expiry_hours, TokenType::Access)
77-
}
78-
79-
/// Create new JWT claims with an explicit token type.
80-
pub fn with_token_type(
81-
user_id: &UserId,
82-
username: &UserName,
83-
role: &Role,
84-
email: Option<&str>,
85-
expiry_hours: Option<i64>,
86-
token_type: TokenType,
87-
) -> Self {
88-
let now = chrono::Utc::now();
89-
let exp_hours = expiry_hours.unwrap_or(DEFAULT_JWT_EXPIRY_HOURS);
90-
let exp = now + chrono::Duration::hours(exp_hours);
91-
92-
Self {
93-
sub: user_id.to_string(),
94-
iss: KALAMDB_ISSUER.to_string(),
95-
exp: exp.timestamp() as usize,
96-
iat: now.timestamp() as usize,
97-
username: Some(username.clone()),
98-
email: email.map(|e| e.to_string()),
99-
role: Some(*role),
100-
token_type: Some(token_type),
101-
}
102-
}
103-
}
104-
10530
/// Generate a new JWT token.
10631
///
10732
/// # Arguments
@@ -133,8 +58,9 @@ pub fn create_and_sign_token(
13358
expiry_hours: Option<i64>,
13459
secret: &str,
13560
) -> AuthResult<(String, JwtClaims)> {
136-
let claims =
137-
JwtClaims::with_token_type(user_id, username, role, email, expiry_hours, TokenType::Access);
61+
let claims = JwtClaims::with_token_type(
62+
user_id, username, role, email, expiry_hours, TokenType::Access, KALAMDB_ISSUER,
63+
);
13864
let token = generate_jwt_token(&claims, secret)?;
13965
Ok((token, claims))
14066
}
@@ -158,6 +84,7 @@ pub fn create_and_sign_refresh_token(
15884
email,
15985
expiry_hours,
16086
TokenType::Refresh,
87+
KALAMDB_ISSUER,
16188
);
16289
let token = generate_jwt_token(&claims, secret)?;
16390
Ok((token, claims))
@@ -258,21 +185,7 @@ pub fn validate_jwt_token(
258185
}
259186

260187
/// Verify JWT issuer is in the trusted list.
261-
///
262-
/// # Arguments
263-
/// * `issuer` - Issuer from JWT claims
264-
/// * `trusted_issuers` - List of trusted issuer domains
265-
///
266-
/// # Returns
267-
/// `Ok(())` if issuer is trusted
268-
///
269-
/// # Errors
270-
/// Returns `AuthError::UntrustedIssuer` if issuer is not in the list
271-
///
272-
/// # Security Note
273-
/// If no trusted issuers are configured, ALL issuers are rejected.
274-
/// This is a secure-by-default approach to prevent accepting arbitrary tokens.
275-
fn verify_issuer(issuer: &str, trusted_issuers: &[String]) -> AuthResult<()> {
188+
pub fn verify_issuer(issuer: &str, trusted_issuers: &[String]) -> AuthResult<()> {
276189
// Security: If no issuers configured, reject all (secure by default)
277190
if trusted_issuers.is_empty() {
278191
return Err(AuthError::UntrustedIssuer(format!(
@@ -288,36 +201,12 @@ fn verify_issuer(issuer: &str, trusted_issuers: &[String]) -> AuthResult<()> {
288201
}
289202
}
290203

291-
/// Extract claims from a JWT token without full validation.
292-
///
293-
/// **WARNING**: This does NOT verify the signature! Only use in tests
294-
/// or when you need to inspect a token before validation.
295-
///
296-
/// # Arguments
297-
/// * `token` - JWT token string
204+
/// Returns true if the issuer is the internal KalamDB issuer.
298205
///
299-
/// # Returns
300-
/// JWT claims (unverified)
301-
///
302-
/// # Errors
303-
/// Returns error if token structure is invalid
304-
///
305-
/// # Security
306-
/// This function is gated behind `#[cfg(test)]` to prevent accidental
307-
/// use in production code paths.
308-
#[cfg(test)]
309-
pub fn extract_claims_unverified(token: &str) -> AuthResult<JwtClaims> {
310-
// Decode without verification (dangerous!)
311-
let mut validation = Validation::new(Algorithm::HS256);
312-
#[allow(deprecated)]
313-
validation.insecure_disable_signature_validation(); // DANGEROUS - but needed for unverified claim extraction
314-
validation.validate_exp = false;
315-
316-
let decoding_key = DecodingKey::from_secret(b""); // Empty key since we're not validating
317-
let token_data = decode::<JwtClaims>(token, &decoding_key, &validation)
318-
.map_err(|e| AuthError::MalformedAuthorization(format!("JWT decode error: {}", e)))?;
319-
320-
Ok(token_data.claims)
206+
/// Internal tokens (iss = "kalamdb") are signed with the shared HS256 secret
207+
/// and never come from an external provider.
208+
pub fn is_internal_issuer(issuer: &str) -> bool {
209+
issuer == KALAMDB_ISSUER
321210
}
322211

323212
#[cfg(test)]

0 commit comments

Comments
 (0)