Skip to content

Commit 3357041

Browse files
committed
fix(pg): classify authentication failures safely
Signed-off-by: DefinitelyNotJosh1 <krasnogo27@up.edu>
1 parent 36879b8 commit 3357041

5 files changed

Lines changed: 207 additions & 14 deletions

File tree

crates/client-api/src/auth.rs

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,8 @@ use http::{request, HeaderValue, StatusCode};
88
use serde::{Deserialize, Serialize};
99
use spacetimedb::auth::identity::{ConnectionAuthCtx, SpacetimeIdentityClaims};
1010
use spacetimedb::auth::identity::{JwtError, JwtErrorKind};
11-
use spacetimedb::auth::token_validation::{
12-
new_validator, DefaultValidator, TokenSigner, TokenValidationError, TokenValidator,
13-
};
11+
use spacetimedb::auth::token_validation::{new_validator, DefaultValidator, TokenSigner, TokenValidator};
12+
pub use spacetimedb::auth::token_validation::{TokenValidationError, TokenValidationErrorCategory};
1413
use spacetimedb::auth::JwtKeys;
1514
use spacetimedb::energy::FunctionBudget;
1615
use spacetimedb::identity::Identity;

crates/core/src/auth/token_validation.rs

Lines changed: 62 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ use super::JwtKeys;
2020

2121
#[derive(thiserror::Error, Debug)]
2222
pub enum TokenValidationError {
23-
// TODO: Add real error types.
23+
// TODO: Replace the remaining dependency-specific and catch-all variants with domain errors.
2424

2525
// TODO: If we had our own errors defined we wouldn't be locked into this lib.
2626
#[error("Invalid token: {0}")]
@@ -29,15 +29,50 @@ pub enum TokenValidationError {
2929
#[error("Specified key ID not found in JWKs")]
3030
KeyIDNotFound,
3131

32+
/// The token was decoded, but its claims are not acceptable.
33+
#[error(transparent)]
34+
InvalidClaims(anyhow::Error),
35+
3236
#[error(transparent)]
3337
JwkError(#[from] jwks::JwkError),
3438
#[error(transparent)]
3539
JwksError(#[from] jwks::JwksError),
40+
41+
/// The identity provider's validation material could not be obtained.
42+
#[error(transparent)]
43+
IdentityProviderUnavailable(anyhow::Error),
44+
3645
// The other case is a catch-all for unexpected errors.
3746
#[error(transparent)]
3847
Other(#[from] anyhow::Error),
3948
}
4049

50+
/// The operational category of a token validation failure.
51+
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
52+
pub enum TokenValidationErrorCategory {
53+
/// The supplied token is invalid and the client may retry with different credentials.
54+
InvalidCredentials,
55+
/// Validation failed because identity-provider data could not be obtained or used.
56+
IdentityProvider,
57+
/// Validation failed because of an unexpected local condition.
58+
Internal,
59+
}
60+
61+
impl TokenValidationError {
62+
/// Classifies this failure for operational handling such as log severity.
63+
pub fn category(&self) -> TokenValidationErrorCategory {
64+
match self {
65+
Self::TokenError(_) | Self::KeyIDNotFound | Self::InvalidClaims(_) => {
66+
TokenValidationErrorCategory::InvalidCredentials
67+
}
68+
Self::JwkError(_) | Self::JwksError(_) | Self::IdentityProviderUnavailable(_) => {
69+
TokenValidationErrorCategory::IdentityProvider
70+
}
71+
Self::Other(_) => TokenValidationErrorCategory::Internal,
72+
}
73+
}
74+
}
75+
4176
// A token signer is responsible for signing tokens without doing any validation.
4277
pub trait TokenSigner: Sync + Send {
4378
// Serialize the given claims and sign a JWT token with them as the payload.
@@ -158,7 +193,7 @@ impl TokenValidator for DecodingKey {
158193

159194
let data = decode::<IncomingClaims>(token, self, &validation)?;
160195
let claims = data.claims;
161-
claims.try_into().map_err(TokenValidationError::Other)
196+
claims.try_into().map_err(TokenValidationError::InvalidClaims)
162197
}
163198
}
164199

@@ -170,7 +205,7 @@ impl TokenValidator for BasicTokenValidator {
170205
if let Some(expected_issuer) = &self.issuer
171206
&& *claims.issuer != **expected_issuer
172207
{
173-
return Err(TokenValidationError::Other(anyhow::anyhow!(
208+
return Err(TokenValidationError::InvalidClaims(anyhow::anyhow!(
174209
"Issuer mismatch: got {:?}, expected {:?}",
175210
claims.issuer,
176211
expected_issuer
@@ -233,7 +268,11 @@ impl TokenValidator for CachingOidcTokenValidator {
233268
.cache
234269
.get(String::from(raw_issuer.clone()).into())
235270
.await
236-
.ok_or_else(|| anyhow::anyhow!("Error fetching public key for issuer {raw_issuer}"))?;
271+
.ok_or_else(|| {
272+
TokenValidationError::IdentityProviderUnavailable(anyhow::anyhow!(
273+
"Error fetching public key for issuer {raw_issuer}"
274+
))
275+
})?;
237276
validator.validate_token(token).await
238277
}
239278
}
@@ -300,7 +339,7 @@ impl TokenValidator for JwksValidator {
300339
log::debug!("No key id in header. Trying all keys.");
301340
// TODO: Consider returning an error if no kid is given?
302341
// For now, lets just try all the keys.
303-
let mut last_error = TokenValidationError::Other(anyhow::anyhow!("No kid found"));
342+
let mut last_error = TokenValidationError::InvalidClaims(anyhow::anyhow!("No kid found"));
304343
for (kid, key) in &self.keyset.keys {
305344
log::debug!("Trying key {kid}");
306345
let validator = BasicTokenValidator {
@@ -328,14 +367,31 @@ mod tests {
328367
use crate::auth::identity::{IncomingClaims, SpacetimeIdentityClaims};
329368
use crate::auth::token_validation::{
330369
BasicTokenValidator, CachingOidcTokenValidator, FullTokenValidator, OidcTokenValidator, TokenSigner,
331-
TokenValidator,
370+
TokenValidationError, TokenValidationErrorCategory, TokenValidator,
332371
};
333372
use crate::auth::JwtKeys;
334373
use base64::Engine;
335374
use openssl::ec::{EcGroup, EcKey};
336375
use serde_json;
337376
use spacetimedb_lib::Identity;
338377

378+
#[test]
379+
fn token_validation_error_categories_distinguish_authentication_failures() {
380+
assert_eq!(
381+
TokenValidationError::KeyIDNotFound.category(),
382+
TokenValidationErrorCategory::InvalidCredentials
383+
);
384+
assert_eq!(
385+
TokenValidationError::IdentityProviderUnavailable(anyhow::anyhow!("controlled provider failure"))
386+
.category(),
387+
TokenValidationErrorCategory::IdentityProvider
388+
);
389+
assert_eq!(
390+
TokenValidationError::Other(anyhow::anyhow!("controlled internal failure")).category(),
391+
TokenValidationErrorCategory::Internal
392+
);
393+
}
394+
339395
#[tokio::test]
340396
async fn test_local_validator_checks_issuer() -> anyhow::Result<()> {
341397
// Test that the issuer must match the expected issuer for LocalTokenValidator.
Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
use std::fmt::Display;
2+
3+
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
4+
pub(crate) enum AuthenticationFailureKind {
5+
InvalidCredentials,
6+
IdentityProvider,
7+
Internal,
8+
}
9+
10+
pub(crate) fn log_authentication_failure(database: &str, kind: AuthenticationFailureKind, err: &impl Display) {
11+
match kind {
12+
AuthenticationFailureKind::InvalidCredentials => {
13+
log::warn!("PG: Authentication failed on database {database}: {err}");
14+
}
15+
AuthenticationFailureKind::IdentityProvider => {
16+
log::error!("PG: Identity provider failed while authenticating to database {database}: {err}");
17+
}
18+
AuthenticationFailureKind::Internal => {
19+
log::error!("PG: Internal authentication failure on database {database}: {err}");
20+
}
21+
}
22+
}
23+
24+
#[cfg(test)]
25+
fn log_authentication_failure_with_supplied_token(
26+
database: &str,
27+
_supplied_token: &str,
28+
kind: AuthenticationFailureKind,
29+
err: &impl Display,
30+
) {
31+
log_authentication_failure(database, kind, err);
32+
}
33+
34+
#[cfg(test)]
35+
mod tests {
36+
use super::*;
37+
use log::{Level, LevelFilter, Log, Metadata, Record};
38+
use std::sync::{Mutex, Once};
39+
40+
const SYNTHETIC_TOKEN: &str = "SYNTHETIC_PG_AUTH_TOKEN_DO_NOT_LOG_5696";
41+
42+
#[derive(Clone, Debug)]
43+
struct CapturedRecord {
44+
level: Level,
45+
target: String,
46+
message: String,
47+
}
48+
49+
struct CapturingLogger {
50+
records: Mutex<Vec<CapturedRecord>>,
51+
}
52+
53+
impl Log for CapturingLogger {
54+
fn enabled(&self, _metadata: &Metadata<'_>) -> bool {
55+
true
56+
}
57+
58+
fn log(&self, record: &Record<'_>) {
59+
self.records
60+
.lock()
61+
.unwrap_or_else(|poisoned| poisoned.into_inner())
62+
.push(CapturedRecord {
63+
level: record.level(),
64+
target: record.target().to_owned(),
65+
message: record.args().to_string(),
66+
});
67+
}
68+
69+
fn flush(&self) {}
70+
}
71+
72+
static LOGGER: CapturingLogger = CapturingLogger {
73+
records: Mutex::new(Vec::new()),
74+
};
75+
static INSTALL_LOGGER: Once = Once::new();
76+
static CAPTURE_LOCK: Mutex<()> = Mutex::new(());
77+
78+
fn capture_authentication_failure(kind: AuthenticationFailureKind) -> CapturedRecord {
79+
let _capture_guard = CAPTURE_LOCK.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
80+
INSTALL_LOGGER.call_once(|| {
81+
log::set_logger(&LOGGER).expect("test logger should only be installed once");
82+
log::set_max_level(LevelFilter::Trace);
83+
});
84+
85+
let mut records = LOGGER.records.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
86+
records.clear();
87+
drop(records);
88+
89+
log_authentication_failure_with_supplied_token(
90+
"synthetic-database",
91+
SYNTHETIC_TOKEN,
92+
kind,
93+
&"controlled authentication failure",
94+
);
95+
96+
let records = LOGGER.records.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
97+
assert_eq!(records.len(), 1, "expected exactly one authentication log record");
98+
records[0].clone()
99+
}
100+
101+
fn assert_token_absent(record: &CapturedRecord) {
102+
assert!(!record.message.contains(SYNTHETIC_TOKEN), "token leaked in log message");
103+
assert!(!record.target.contains(SYNTHETIC_TOKEN), "token leaked in log target");
104+
}
105+
106+
#[test]
107+
fn invalid_credentials_are_warned_without_logging_the_token() {
108+
let record = capture_authentication_failure(AuthenticationFailureKind::InvalidCredentials);
109+
110+
assert_eq!(record.level, Level::Warn);
111+
assert_token_absent(&record);
112+
}
113+
114+
#[test]
115+
fn identity_provider_failures_are_errors_without_logging_the_token() {
116+
let record = capture_authentication_failure(AuthenticationFailureKind::IdentityProvider);
117+
118+
assert_eq!(record.level, Level::Error);
119+
assert_token_absent(&record);
120+
}
121+
122+
#[test]
123+
fn internal_authentication_failures_are_errors_without_logging_the_token() {
124+
let record = capture_authentication_failure(AuthenticationFailureKind::Internal);
125+
126+
assert_eq!(record.level, Level::Error);
127+
assert_token_absent(&record);
128+
}
129+
}

crates/pg/src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
1+
mod authentication_logging;
12
mod encoder;
23
pub mod pg_server;

crates/pg/src/pg_server.rs

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ use pgwire::messages::startup::Authentication;
2323
use pgwire::messages::{PgWireBackendMessage, PgWireFrontendMessage};
2424
use pgwire::tokio::process_socket;
2525
use spacetimedb_auth::identity::ConnectionAuthCtx;
26-
use spacetimedb_client_api::auth::validate_token;
26+
use spacetimedb_client_api::auth::{validate_token, TokenValidationError, TokenValidationErrorCategory};
2727
use spacetimedb_client_api::routes::database;
2828
use spacetimedb_client_api::routes::database::{SqlParams, SqlQueryParams};
2929
use spacetimedb_client_api::{Authorization, ControlStateReadAccess, ControlStateWriteAccess, NodeDelegate};
@@ -37,6 +37,8 @@ use thiserror::Error;
3737
use tokio::net::TcpListener;
3838
use tokio::sync::{Mutex, Notify};
3939

40+
use crate::authentication_logging::{log_authentication_failure, AuthenticationFailureKind};
41+
4042
#[derive(Error, Debug)]
4143
pub(crate) enum PgError {
4244
#[error("(metadata) {0}")]
@@ -51,6 +53,14 @@ pub(crate) enum PgError {
5153
Other(#[from] anyhow::Error),
5254
}
5355

56+
fn authentication_failure_kind(err: &TokenValidationError) -> AuthenticationFailureKind {
57+
match err.category() {
58+
TokenValidationErrorCategory::InvalidCredentials => AuthenticationFailureKind::InvalidCredentials,
59+
TokenValidationErrorCategory::IdentityProvider => AuthenticationFailureKind::IdentityProvider,
60+
TokenValidationErrorCategory::Internal => AuthenticationFailureKind::Internal,
61+
}
62+
}
63+
5464
impl From<PgError> for PgWireError {
5565
fn from(err: PgError) -> Self {
5666
if let PgError::Pg(err) = err {
@@ -271,10 +281,8 @@ impl<T: Sync + Send + ControlStateReadAccess + ControlStateWriteAccess + NodeDel
271281
let claims = match validate_token(&self.ctx, &pwd.password).await {
272282
Ok(claims) => claims,
273283
Err(err) => {
274-
log::error!(
275-
"PG: Authentication failed for identity `{}` on database {database}: {err}",
276-
pwd.password
277-
);
284+
let kind = authentication_failure_kind(&err);
285+
log_authentication_failure(&database, kind, &err);
278286
let err = ErrorInfo::new("FATAL".to_owned(), "28P01".to_owned(), err.to_string());
279287
return close_client(client, err).await;
280288
}

0 commit comments

Comments
 (0)