mirror of
https://github.com/fawney19/Aether.git
synced 2026-10-07 18:07:47 +08:00
feat(security): harden gateway boundaries and usage policies
Consolidate subscription usage policy enforcement, privacy-safe persistence, and gateway security hardening into one reviewable change. Includes bounded HTTP and execution envelopes, header and protocol guards, DNS and relay validation, authentication and secret projection hardening, secure backup/install paths, and regression coverage.
This commit is contained in:
@@ -1,28 +1,298 @@
|
||||
use thiserror::Error;
|
||||
use aether_contracts::redact_url_for_debug;
|
||||
use serde_json::{json, Value};
|
||||
|
||||
const OAUTH_ERROR_BODY_EXCERPT_CHARS: usize = 500;
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum OAuthError {
|
||||
#[error("unsupported oauth provider: {0}")]
|
||||
UnsupportedProvider(String),
|
||||
#[error("invalid oauth request: {0}")]
|
||||
InvalidRequest(String),
|
||||
#[error("oauth state is invalid or expired")]
|
||||
InvalidState,
|
||||
#[error("oauth provider returned HTTP {status_code}: {body_excerpt}")]
|
||||
// `body_excerpt` remains available to trusted callers for status
|
||||
// classification, but must not be rendered by the generic Error/Debug
|
||||
// paths: OAuth servers sometimes echo access tokens, authorization codes,
|
||||
// assertions, or client credentials in an error response.
|
||||
HttpStatus {
|
||||
status_code: u16,
|
||||
body_excerpt: String,
|
||||
},
|
||||
#[error("oauth provider returned invalid response: {0}")]
|
||||
InvalidResponse(String),
|
||||
#[error("oauth transport failed: {0}")]
|
||||
Transport(String),
|
||||
#[error("oauth storage failed: {0}")]
|
||||
Storage(String),
|
||||
#[error("oauth encryption failed")]
|
||||
EncryptionUnavailable,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for OAuthError {
|
||||
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::UnsupportedProvider(detail) => write!(
|
||||
formatter,
|
||||
"unsupported oauth provider: {}",
|
||||
redact_oauth_error_detail(detail)
|
||||
),
|
||||
Self::InvalidRequest(detail) => write!(
|
||||
formatter,
|
||||
"invalid oauth request: {}",
|
||||
redact_oauth_error_detail(detail)
|
||||
),
|
||||
Self::InvalidState => formatter.write_str("oauth state is invalid or expired"),
|
||||
Self::HttpStatus { status_code, .. } => {
|
||||
write!(formatter, "oauth provider returned HTTP {status_code}")
|
||||
}
|
||||
Self::InvalidResponse(detail) => write!(
|
||||
formatter,
|
||||
"oauth provider returned invalid response: {}",
|
||||
redact_oauth_error_detail(detail)
|
||||
),
|
||||
Self::Transport(detail) => write!(
|
||||
formatter,
|
||||
"oauth transport failed: {}",
|
||||
redact_oauth_error_detail(detail)
|
||||
),
|
||||
Self::Storage(detail) => write!(
|
||||
formatter,
|
||||
"oauth storage failed: {}",
|
||||
redact_oauth_error_detail(detail)
|
||||
),
|
||||
Self::EncryptionUnavailable => formatter.write_str("oauth encryption failed"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for OAuthError {}
|
||||
|
||||
impl std::fmt::Debug for OAuthError {
|
||||
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::UnsupportedProvider(_) => formatter
|
||||
.debug_tuple("UnsupportedProvider")
|
||||
.field(&"[REDACTED]")
|
||||
.finish(),
|
||||
Self::InvalidRequest(_) => formatter
|
||||
.debug_tuple("InvalidRequest")
|
||||
.field(&"[REDACTED]")
|
||||
.finish(),
|
||||
Self::InvalidState => formatter.write_str("InvalidState"),
|
||||
Self::HttpStatus { status_code, .. } => formatter
|
||||
.debug_struct("HttpStatus")
|
||||
.field("status_code", status_code)
|
||||
.field("body_excerpt", &"[REDACTED]")
|
||||
.finish(),
|
||||
Self::InvalidResponse(_) => formatter
|
||||
.debug_tuple("InvalidResponse")
|
||||
.field(&"[REDACTED]")
|
||||
.finish(),
|
||||
Self::Transport(_) => formatter
|
||||
.debug_tuple("Transport")
|
||||
.field(&"[REDACTED]")
|
||||
.finish(),
|
||||
Self::Storage(_) => formatter
|
||||
.debug_tuple("Storage")
|
||||
.field(&"[REDACTED]")
|
||||
.finish(),
|
||||
Self::EncryptionUnavailable => formatter.write_str("EncryptionUnavailable"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Builds an error excerpt that is safe to persist or render in diagnostics.
|
||||
///
|
||||
/// Structured responses retain non-sensitive provider error codes/messages so
|
||||
/// callers can still classify `invalid_grant` and similar failures. Secret
|
||||
/// fields and secret-shaped values are removed before the size bound is
|
||||
/// applied. Unstructured bodies containing credential markers are replaced as
|
||||
/// a whole because their token boundaries cannot be determined reliably.
|
||||
pub fn redacted_oauth_error_body_excerpt(body: &str) -> String {
|
||||
let body = body.trim();
|
||||
if body.is_empty() {
|
||||
return "-".to_string();
|
||||
}
|
||||
|
||||
if let Ok(mut value) = serde_json::from_str::<Value>(body) {
|
||||
redact_oauth_error_json(&mut value);
|
||||
return value
|
||||
.to_string()
|
||||
.chars()
|
||||
.take(OAUTH_ERROR_BODY_EXCERPT_CHARS)
|
||||
.collect();
|
||||
}
|
||||
|
||||
if unstructured_body_may_contain_secret(body) {
|
||||
"[REDACTED upstream OAuth error body]".to_string()
|
||||
} else {
|
||||
body.chars().take(OAUTH_ERROR_BODY_EXCERPT_CHARS).collect()
|
||||
}
|
||||
}
|
||||
|
||||
fn redact_oauth_error_json(value: &mut Value) {
|
||||
match value {
|
||||
Value::Object(object) => {
|
||||
for (key, value) in object {
|
||||
if oauth_error_key_is_sensitive(key) {
|
||||
*value = json!("[REDACTED]");
|
||||
} else {
|
||||
redact_oauth_error_json(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
Value::Array(items) => {
|
||||
for item in items {
|
||||
redact_oauth_error_json(item);
|
||||
}
|
||||
}
|
||||
Value::String(text) => {
|
||||
if oauth_error_value_is_safe_classification_code(text) {
|
||||
// Keep a small allowlist of non-secret provider error codes for classification.
|
||||
} else if oauth_error_value_looks_secret(text)
|
||||
|| unstructured_body_may_contain_secret(text)
|
||||
{
|
||||
*text = "[REDACTED]".to_string();
|
||||
} else {
|
||||
*text = redact_urls_in_text(text);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn oauth_error_key_is_sensitive(key: &str) -> bool {
|
||||
let normalized = key
|
||||
.chars()
|
||||
.filter(|ch| ch.is_ascii_alphanumeric())
|
||||
.collect::<String>()
|
||||
.to_ascii_lowercase();
|
||||
normalized.contains("token")
|
||||
|| normalized.contains("apikey")
|
||||
|| normalized.contains("password")
|
||||
|| normalized.contains("authorization")
|
||||
|| normalized.contains("secret")
|
||||
|| normalized.contains("clientsecret")
|
||||
|| normalized.contains("privatekey")
|
||||
|| normalized.contains("assertion")
|
||||
|| normalized.contains("credential")
|
||||
|| normalized.contains("cookie")
|
||||
|| normalized.contains("pkce")
|
||||
|| normalized.contains("verifier")
|
||||
|| normalized == "sessionkey"
|
||||
}
|
||||
|
||||
fn oauth_error_value_looks_secret(value: &str) -> bool {
|
||||
let value = value.trim();
|
||||
value.starts_with("Bearer ")
|
||||
|| value.starts_with("bearer ")
|
||||
|| value.starts_with("sk-")
|
||||
|| value.starts_with("sess-")
|
||||
|| value.starts_with("devin-session-token$")
|
||||
|| value.starts_with("ott$")
|
||||
|| value.starts_with("auth1_")
|
||||
|| (value.len() > 80
|
||||
&& value.split('.').count() == 3
|
||||
&& value.split('.').all(|segment| {
|
||||
!segment.is_empty()
|
||||
&& segment.bytes().all(|byte| {
|
||||
byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'=')
|
||||
})
|
||||
}))
|
||||
}
|
||||
|
||||
fn oauth_error_value_is_safe_classification_code(value: &str) -> bool {
|
||||
matches!(
|
||||
value.trim().to_ascii_lowercase().as_str(),
|
||||
"refresh_token_reused" | "refresh_token_expired" | "invalid_refresh_token"
|
||||
)
|
||||
}
|
||||
|
||||
/// Redact dynamic OAuth error details before they reach `Display` consumers.
|
||||
/// Error details frequently originate in HTTP clients and may contain a full
|
||||
/// request URL or an upstream response body.
|
||||
fn redact_oauth_error_detail(detail: &str) -> String {
|
||||
let excerpt = redacted_oauth_error_body_excerpt(detail);
|
||||
redact_urls_in_text(&excerpt)
|
||||
.chars()
|
||||
.take(OAUTH_ERROR_BODY_EXCERPT_CHARS)
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn redact_urls_in_text(text: &str) -> String {
|
||||
const URL_SCHEMES: [&str; 4] = ["http://", "https://", "ws://", "wss://"];
|
||||
let mut output = String::with_capacity(text.len());
|
||||
let mut cursor = 0;
|
||||
|
||||
while cursor < text.len() {
|
||||
let Some((relative_start, scheme)) = URL_SCHEMES
|
||||
.iter()
|
||||
.filter_map(|scheme| text[cursor..].find(scheme).map(|start| (start, *scheme)))
|
||||
.min_by_key(|(start, _)| *start)
|
||||
else {
|
||||
output.push_str(&text[cursor..]);
|
||||
break;
|
||||
};
|
||||
|
||||
let start = cursor + relative_start;
|
||||
output.push_str(&text[cursor..start]);
|
||||
let token_end = text[start..]
|
||||
.find(char::is_whitespace)
|
||||
.map(|offset| start + offset)
|
||||
.unwrap_or(text.len());
|
||||
let token = &text[start..token_end];
|
||||
let (url_token, suffix) = trim_url_suffix(token);
|
||||
if url_token.starts_with(scheme) {
|
||||
if url::Url::parse(url_token).is_ok() {
|
||||
output.push_str(&redact_url_for_debug(url_token));
|
||||
} else {
|
||||
output.push_str("[REDACTED URL]");
|
||||
}
|
||||
output.push_str(suffix);
|
||||
} else {
|
||||
output.push_str(token);
|
||||
}
|
||||
cursor = token_end;
|
||||
}
|
||||
|
||||
output
|
||||
}
|
||||
|
||||
fn trim_url_suffix(token: &str) -> (&str, &str) {
|
||||
let mut end = token.len();
|
||||
while end > 0 {
|
||||
let Some(ch) = token[..end].chars().next_back() else {
|
||||
break;
|
||||
};
|
||||
if matches!(
|
||||
ch,
|
||||
'.' | ',' | ';' | ':' | '!' | '?' | ')' | ']' | '}' | '\'' | '"'
|
||||
) {
|
||||
end -= ch.len_utf8();
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
(&token[..end], &token[end..])
|
||||
}
|
||||
|
||||
fn unstructured_body_may_contain_secret(value: &str) -> bool {
|
||||
let normalized = value.to_ascii_lowercase();
|
||||
[
|
||||
"access_token",
|
||||
"refresh_token",
|
||||
"id_token",
|
||||
"api_key",
|
||||
"apikey",
|
||||
"authorization:",
|
||||
"authorization=",
|
||||
"client_secret",
|
||||
"secret=",
|
||||
"secret:",
|
||||
"password=",
|
||||
"password:",
|
||||
"assertion=",
|
||||
"session_token",
|
||||
"sessiontoken",
|
||||
]
|
||||
.iter()
|
||||
.any(|marker| normalized.contains(marker))
|
||||
|| oauth_error_value_looks_secret(value)
|
||||
}
|
||||
|
||||
impl OAuthError {
|
||||
pub fn invalid_request(detail: impl Into<String>) -> Self {
|
||||
Self::InvalidRequest(detail.into())
|
||||
@@ -36,3 +306,94 @@ impl OAuthError {
|
||||
Self::Transport(detail.into())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{redacted_oauth_error_body_excerpt, OAuthError};
|
||||
|
||||
#[test]
|
||||
fn oauth_error_body_excerpt_preserves_classification_and_redacts_secrets() {
|
||||
let excerpt = redacted_oauth_error_body_excerpt(
|
||||
r#"{
|
||||
"error": {
|
||||
"code": "invalid_grant",
|
||||
"message": "refresh token expired",
|
||||
"refresh_token": "refresh-body-canary",
|
||||
"nested": {"clientSecret": "client-secret-canary"}
|
||||
},
|
||||
"accessToken": "access-token-canary"
|
||||
}"#,
|
||||
);
|
||||
|
||||
assert!(excerpt.contains("invalid_grant"));
|
||||
assert!(excerpt.contains("refresh token expired"));
|
||||
assert!(!excerpt.contains("refresh-body-canary"));
|
||||
assert!(!excerpt.contains("client-secret-canary"));
|
||||
assert!(!excerpt.contains("access-token-canary"));
|
||||
assert!(excerpt.contains("[REDACTED]"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn oauth_error_debug_and_display_do_not_render_upstream_body() {
|
||||
let error = OAuthError::HttpStatus {
|
||||
status_code: 401,
|
||||
body_excerpt: "authorization=Bearer oauth-error-canary".to_string(),
|
||||
};
|
||||
|
||||
let debug = format!("{error:?}");
|
||||
let display = error.to_string();
|
||||
assert!(!debug.contains("oauth-error-canary"));
|
||||
assert!(!display.contains("oauth-error-canary"));
|
||||
assert!(debug.contains("[REDACTED]"));
|
||||
assert_eq!(display, "oauth provider returned HTTP 401");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unstructured_oauth_error_with_secret_markers_is_replaced() {
|
||||
let excerpt = redacted_oauth_error_body_excerpt(
|
||||
"invalid request: refresh_token=plain-text-refresh-canary",
|
||||
);
|
||||
assert_eq!(excerpt, "[REDACTED upstream OAuth error body]");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn oauth_error_body_excerpt_preserves_long_refresh_rotation_message() {
|
||||
let body = r#"{"error":{"message":"Your refresh token has already been used to generate a new access token. Please try signing in again.","type":"invalid_request_error","param":null,"code":"refresh_token_reused"}}"#;
|
||||
let excerpt = redacted_oauth_error_body_excerpt(body);
|
||||
assert!(excerpt.contains("already been used to generate a new access token"));
|
||||
assert!(excerpt.contains("refresh_token_reused"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dynamic_oauth_error_display_redacts_credentials_and_url_queries() {
|
||||
let detail =
|
||||
"apiKey=sk-secret token=secret-token https://user:[email protected]?q=url-secret";
|
||||
for error in [
|
||||
OAuthError::UnsupportedProvider(detail.to_string()),
|
||||
OAuthError::InvalidRequest(detail.to_string()),
|
||||
OAuthError::InvalidResponse(detail.to_string()),
|
||||
OAuthError::Transport(detail.to_string()),
|
||||
OAuthError::Storage(detail.to_string()),
|
||||
] {
|
||||
let display = error.to_string();
|
||||
for secret in ["sk-secret", "secret-token", "user", "pass", "url-secret"] {
|
||||
assert!(
|
||||
!display.contains(secret),
|
||||
"display leaked {secret}: {display}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dynamic_oauth_error_display_redacts_standalone_url() {
|
||||
let error = OAuthError::invalid_response(
|
||||
"upstream request failed at https://user:[email protected]/path?code=secret",
|
||||
);
|
||||
let display = error.to_string();
|
||||
assert!(!display.contains("user"));
|
||||
assert!(!display.contains("pass"));
|
||||
assert!(!display.contains("code=secret"));
|
||||
assert!(display.contains("https://example.test/path"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
#[derive(Clone, PartialEq, Eq)]
|
||||
pub struct OAuthProviderMetadata {
|
||||
pub provider_type: String,
|
||||
pub display_name: String,
|
||||
@@ -13,7 +13,27 @@ pub struct OAuthProviderMetadata {
|
||||
pub use_pkce: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
impl std::fmt::Debug for OAuthProviderMetadata {
|
||||
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
formatter
|
||||
.debug_struct("OAuthProviderMetadata")
|
||||
.field("provider_type", &self.provider_type)
|
||||
.field("display_name", &self.display_name)
|
||||
.field("authorize_url", &"[REDACTED]")
|
||||
.field("token_url", &"[REDACTED]")
|
||||
.field("client_id", &self.client_id)
|
||||
.field(
|
||||
"client_secret",
|
||||
&self.client_secret.as_ref().map(|_| "[REDACTED]"),
|
||||
)
|
||||
.field("scopes", &self.scopes)
|
||||
.field("redirect_uri", &self.redirect_uri)
|
||||
.field("use_pkce", &self.use_pkce)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, PartialEq, Eq)]
|
||||
pub struct OAuthAuthorizeRequest {
|
||||
pub state: String,
|
||||
pub code_challenge: Option<String>,
|
||||
@@ -21,7 +41,22 @@ pub struct OAuthAuthorizeRequest {
|
||||
pub login_hint: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||
impl std::fmt::Debug for OAuthAuthorizeRequest {
|
||||
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
formatter
|
||||
.debug_struct("OAuthAuthorizeRequest")
|
||||
.field("state", &"[REDACTED]")
|
||||
.field(
|
||||
"code_challenge",
|
||||
&self.code_challenge.as_ref().map(|_| "[REDACTED]"),
|
||||
)
|
||||
.field("prompt", &self.prompt)
|
||||
.field("has_login_hint", &self.login_hint.is_some())
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, PartialEq, Eq, Serialize)]
|
||||
pub struct OAuthAuthorizeResponse {
|
||||
pub authorize_url: String,
|
||||
pub state: String,
|
||||
@@ -29,14 +64,39 @@ pub struct OAuthAuthorizeResponse {
|
||||
pub code_challenge: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
impl std::fmt::Debug for OAuthAuthorizeResponse {
|
||||
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
formatter
|
||||
.debug_struct("OAuthAuthorizeResponse")
|
||||
.field("authorize_url", &"[REDACTED]")
|
||||
.field("state", &"[REDACTED]")
|
||||
.field(
|
||||
"code_challenge",
|
||||
&self.code_challenge.as_ref().map(|_| "[REDACTED]"),
|
||||
)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, PartialEq, Eq)]
|
||||
pub struct OAuthCallback {
|
||||
pub code: String,
|
||||
pub state: String,
|
||||
pub scope: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
|
||||
impl std::fmt::Debug for OAuthCallback {
|
||||
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
formatter
|
||||
.debug_struct("OAuthCallback")
|
||||
.field("code", &"[REDACTED]")
|
||||
.field("state", &"[REDACTED]")
|
||||
.field("scope", &self.scope)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, PartialEq, Eq, Deserialize, Serialize)]
|
||||
pub struct OAuthDeviceAuthorization {
|
||||
pub device_code: String,
|
||||
pub user_code: String,
|
||||
@@ -45,3 +105,86 @@ pub struct OAuthDeviceAuthorization {
|
||||
pub expires_in: u64,
|
||||
pub interval: u64,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for OAuthDeviceAuthorization {
|
||||
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
formatter
|
||||
.debug_struct("OAuthDeviceAuthorization")
|
||||
.field("device_code", &"[REDACTED]")
|
||||
.field("user_code", &"[REDACTED]")
|
||||
.field("verification_uri", &self.verification_uri)
|
||||
.field("verification_uri_complete", &"[REDACTED]")
|
||||
.field("expires_in", &self.expires_in)
|
||||
.field("interval", &self.interval)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
OAuthAuthorizeRequest, OAuthAuthorizeResponse, OAuthCallback, OAuthDeviceAuthorization,
|
||||
OAuthProviderMetadata,
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn oauth_flow_debug_output_redacts_capabilities_and_secrets() {
|
||||
let metadata = OAuthProviderMetadata {
|
||||
provider_type: "test".to_string(),
|
||||
display_name: "Test".to_string(),
|
||||
authorize_url: "https://idp.example/authorize".to_string(),
|
||||
token_url: "https://idp.example/token".to_string(),
|
||||
client_id: "public-client".to_string(),
|
||||
client_secret: Some("client-secret-canary".to_string()),
|
||||
scopes: vec!["openid".to_string()],
|
||||
redirect_uri: "https://gateway.example/callback".to_string(),
|
||||
use_pkce: true,
|
||||
};
|
||||
let request = OAuthAuthorizeRequest {
|
||||
state: "state-canary".to_string(),
|
||||
code_challenge: Some("challenge-canary".to_string()),
|
||||
prompt: None,
|
||||
login_hint: Some("login-hint-canary".to_string()),
|
||||
};
|
||||
let response = OAuthAuthorizeResponse {
|
||||
authorize_url:
|
||||
"https://idp.example/authorize?state=state-url-canary&code_challenge=challenge"
|
||||
.to_string(),
|
||||
state: "response-state-canary".to_string(),
|
||||
code_challenge: Some("response-challenge-canary".to_string()),
|
||||
};
|
||||
let callback = OAuthCallback {
|
||||
code: "authorization-code-canary".to_string(),
|
||||
state: "callback-state-canary".to_string(),
|
||||
scope: None,
|
||||
};
|
||||
let device = OAuthDeviceAuthorization {
|
||||
device_code: "device-code-canary".to_string(),
|
||||
user_code: "user-code-canary".to_string(),
|
||||
verification_uri: "https://idp.example/device".to_string(),
|
||||
verification_uri_complete: "https://idp.example/device?code=complete-canary"
|
||||
.to_string(),
|
||||
expires_in: 600,
|
||||
interval: 5,
|
||||
};
|
||||
|
||||
let debug = format!("{metadata:?} {request:?} {response:?} {callback:?} {device:?}");
|
||||
for secret in [
|
||||
"client-secret-canary",
|
||||
"state-canary",
|
||||
"challenge-canary",
|
||||
"login-hint-canary",
|
||||
"state-url-canary",
|
||||
"response-state-canary",
|
||||
"response-challenge-canary",
|
||||
"authorization-code-canary",
|
||||
"callback-state-canary",
|
||||
"device-code-canary",
|
||||
"user-code-canary",
|
||||
"complete-canary",
|
||||
] {
|
||||
assert!(!debug.contains(secret), "debug leaked {secret}");
|
||||
}
|
||||
assert!(debug.contains("[REDACTED]"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ mod pkce;
|
||||
mod registry;
|
||||
mod token;
|
||||
|
||||
pub use error::OAuthError;
|
||||
pub use error::{redacted_oauth_error_body_excerpt, OAuthError};
|
||||
pub use flow::{
|
||||
OAuthAuthorizeRequest, OAuthAuthorizeResponse, OAuthCallback, OAuthDeviceAuthorization,
|
||||
OAuthProviderMetadata,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use serde_json::Value;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
#[derive(Clone, PartialEq)]
|
||||
pub struct OAuthTokenSet {
|
||||
pub access_token: String,
|
||||
pub refresh_token: Option<String>,
|
||||
@@ -11,6 +11,26 @@ pub struct OAuthTokenSet {
|
||||
pub raw_payload: Option<Value>,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for OAuthTokenSet {
|
||||
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
formatter
|
||||
.debug_struct("OAuthTokenSet")
|
||||
.field("access_token", &"<redacted>")
|
||||
.field(
|
||||
"refresh_token",
|
||||
&self.refresh_token.as_ref().map(|_| "<redacted>"),
|
||||
)
|
||||
.field("token_type", &self.token_type)
|
||||
.field("scope", &self.scope)
|
||||
.field("expires_at_unix_secs", &self.expires_at_unix_secs)
|
||||
.field(
|
||||
"raw_payload",
|
||||
&self.raw_payload.as_ref().map(|_| "<redacted>"),
|
||||
)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl OAuthTokenSet {
|
||||
pub fn from_token_payload(payload: Value) -> Option<Self> {
|
||||
let access_token = non_empty_string(payload.get("access_token"))
|
||||
@@ -109,4 +129,20 @@ mod tests {
|
||||
assert!(token.expires_at_unix_secs.is_some());
|
||||
assert_eq!(token.bearer_header_value(), "Bearer access");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn debug_output_redacts_tokens_and_raw_payload() {
|
||||
let token = OAuthTokenSet::from_token_payload(json!({
|
||||
"access_token": "access-secret-sentinel",
|
||||
"refresh_token": "refresh-secret-sentinel",
|
||||
"provider_secret": "raw-secret-sentinel"
|
||||
}))
|
||||
.expect("token should parse");
|
||||
|
||||
let debug = format!("{token:?}");
|
||||
assert!(!debug.contains("access-secret-sentinel"));
|
||||
assert!(!debug.contains("refresh-secret-sentinel"));
|
||||
assert!(!debug.contains("raw-secret-sentinel"));
|
||||
assert!(debug.contains("<redacted>"));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user