mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
## Why PR #21843 removed the TCP websocket app-server listener, but that also removed functionality that still needs to exist. Restoring it as-is would reopen the old remote exposure problem, so this keeps the restored listener while making remote and non-loopback usage require explicit auth. ## What Changed - Mostly reverts #21843 and reapplies the small merge-conflict resolutions needed on top of current main. - Restores ws://IP:PORT parsing, the app-server TCP websocket acceptor, websocket auth CLI flags, and the associated tests. - The only intentional behavior change from the restored code is that non-loopback websocket listeners now fail startup unless --ws-auth capability-token or --ws-auth signed-bearer-token is configured. Loopback listeners remain available for local and SSH-forwarding workflows. ## Reviewer Focus Please focus review on the small auth-enforcement delta layered on top of the revert: - codex-rs/app-server-transport/src/transport/websocket.rs: start_websocket_acceptor now rejects unauthenticated non-loopback websocket binds before accepting connections. - codex-rs/app-server-transport/src/transport/auth.rs: helper logic classifies unauthenticated non-loopback listeners. - codex-rs/app-server/tests/suite/v2/connection_handling_websocket.rs: tests cover unauthenticated ws://0.0.0.0 startup rejection and authenticated non-loopback capability-token startup. Everything else is intended to be revert/merge-conflict restoration rather than new product behavior. ## Verification - Manually verified that TUI remoting is restored and that auth is enforced for non-localhost urls.
752 lines
26 KiB
Rust
752 lines
26 KiB
Rust
use anyhow::Context;
|
|
use axum::http::HeaderMap;
|
|
use axum::http::StatusCode;
|
|
use axum::http::header::AUTHORIZATION;
|
|
use clap::Args;
|
|
use clap::ValueEnum;
|
|
use codex_utils_absolute_path::AbsolutePathBuf;
|
|
use constant_time_eq::constant_time_eq_32;
|
|
use jsonwebtoken::Algorithm;
|
|
use jsonwebtoken::DecodingKey;
|
|
use jsonwebtoken::Validation;
|
|
use jsonwebtoken::decode;
|
|
use serde::Deserialize;
|
|
use sha2::Digest;
|
|
use sha2::Sha256;
|
|
use std::io;
|
|
use std::io::ErrorKind;
|
|
use std::net::SocketAddr;
|
|
use std::path::Path;
|
|
use std::path::PathBuf;
|
|
use time::OffsetDateTime;
|
|
|
|
const DEFAULT_MAX_CLOCK_SKEW_SECONDS: u64 = 30;
|
|
const MIN_SIGNED_BEARER_SECRET_BYTES: usize = 32;
|
|
const INVALID_AUTHORIZATION_HEADER_MESSAGE: &str = "invalid authorization header";
|
|
|
|
#[derive(Debug, Clone, Default, PartialEq, Eq, Args)]
|
|
pub struct AppServerWebsocketAuthArgs {
|
|
/// Websocket auth mode for non-loopback listeners.
|
|
#[arg(long = "ws-auth", value_name = "MODE", value_enum)]
|
|
pub ws_auth: Option<WebsocketAuthCliMode>,
|
|
|
|
/// Absolute path to the capability-token file.
|
|
#[arg(long = "ws-token-file", value_name = "PATH")]
|
|
pub ws_token_file: Option<PathBuf>,
|
|
|
|
/// Hex-encoded SHA-256 digest of the capability token.
|
|
#[arg(long = "ws-token-sha256", value_name = "HEX")]
|
|
pub ws_token_sha256: Option<String>,
|
|
|
|
/// Absolute path to the shared secret file for signed JWT bearer tokens.
|
|
#[arg(long = "ws-shared-secret-file", value_name = "PATH")]
|
|
pub ws_shared_secret_file: Option<PathBuf>,
|
|
|
|
/// Expected issuer for signed JWT bearer tokens.
|
|
#[arg(long = "ws-issuer", value_name = "ISSUER")]
|
|
pub ws_issuer: Option<String>,
|
|
|
|
/// Expected audience for signed JWT bearer tokens.
|
|
#[arg(long = "ws-audience", value_name = "AUDIENCE")]
|
|
pub ws_audience: Option<String>,
|
|
|
|
/// Maximum clock skew when validating signed JWT bearer tokens.
|
|
#[arg(long = "ws-max-clock-skew-seconds", value_name = "SECONDS")]
|
|
pub ws_max_clock_skew_seconds: Option<u64>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
|
|
pub enum WebsocketAuthCliMode {
|
|
CapabilityToken,
|
|
SignedBearerToken,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
|
pub struct AppServerWebsocketAuthSettings {
|
|
pub config: Option<AppServerWebsocketAuthConfig>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub enum AppServerWebsocketAuthConfig {
|
|
CapabilityToken {
|
|
source: AppServerWebsocketCapabilityTokenSource,
|
|
},
|
|
SignedBearerToken {
|
|
shared_secret_file: AbsolutePathBuf,
|
|
issuer: Option<String>,
|
|
audience: Option<String>,
|
|
max_clock_skew_seconds: u64,
|
|
},
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub enum AppServerWebsocketCapabilityTokenSource {
|
|
TokenFile { token_file: AbsolutePathBuf },
|
|
TokenSha256 { token_sha256: [u8; 32] },
|
|
}
|
|
|
|
#[derive(Clone, Debug, Default)]
|
|
pub struct WebsocketAuthPolicy {
|
|
pub(crate) mode: Option<WebsocketAuthMode>,
|
|
}
|
|
|
|
#[derive(Clone, Debug)]
|
|
pub(crate) enum WebsocketAuthMode {
|
|
CapabilityToken {
|
|
token_sha256: [u8; 32],
|
|
},
|
|
SignedBearerToken {
|
|
shared_secret: Vec<u8>,
|
|
issuer: Option<String>,
|
|
audience: Option<String>,
|
|
max_clock_skew_seconds: i64,
|
|
},
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
pub(crate) struct WebsocketAuthError {
|
|
status_code: StatusCode,
|
|
message: &'static str,
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
struct JwtClaims {
|
|
exp: i64,
|
|
nbf: Option<i64>,
|
|
iss: Option<String>,
|
|
aud: Option<JwtAudienceClaim>,
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
#[serde(untagged)]
|
|
enum JwtAudienceClaim {
|
|
Single(String),
|
|
Multiple(Vec<String>),
|
|
}
|
|
|
|
impl WebsocketAuthError {
|
|
pub(crate) fn status_code(&self) -> StatusCode {
|
|
self.status_code
|
|
}
|
|
|
|
pub(crate) fn message(&self) -> &'static str {
|
|
self.message
|
|
}
|
|
}
|
|
|
|
impl AppServerWebsocketAuthArgs {
|
|
pub fn try_into_settings(self) -> anyhow::Result<AppServerWebsocketAuthSettings> {
|
|
let normalize = |value: Option<String>| {
|
|
value.and_then(|value| {
|
|
let trimmed = value.trim();
|
|
(!trimmed.is_empty()).then(|| trimmed.to_string())
|
|
})
|
|
};
|
|
|
|
let config = match self.ws_auth {
|
|
Some(WebsocketAuthCliMode::CapabilityToken) => {
|
|
if self.ws_shared_secret_file.is_some()
|
|
|| self.ws_issuer.is_some()
|
|
|| self.ws_audience.is_some()
|
|
|| self.ws_max_clock_skew_seconds.is_some()
|
|
{
|
|
anyhow::bail!(
|
|
"`--ws-shared-secret-file`, `--ws-issuer`, `--ws-audience`, and `--ws-max-clock-skew-seconds` require `--ws-auth signed-bearer-token`"
|
|
);
|
|
}
|
|
let source = match (self.ws_token_file, self.ws_token_sha256) {
|
|
(Some(_), Some(_)) => {
|
|
anyhow::bail!(
|
|
"`--ws-token-file` and `--ws-token-sha256` are mutually exclusive"
|
|
);
|
|
}
|
|
(Some(token_file), None) => {
|
|
AppServerWebsocketCapabilityTokenSource::TokenFile {
|
|
token_file: absolute_path_arg("--ws-token-file", token_file)?,
|
|
}
|
|
}
|
|
(None, Some(token_sha256)) => {
|
|
AppServerWebsocketCapabilityTokenSource::TokenSha256 {
|
|
token_sha256: sha256_digest_arg("--ws-token-sha256", &token_sha256)?,
|
|
}
|
|
}
|
|
(None, None) => {
|
|
anyhow::bail!(
|
|
"`--ws-token-file` or `--ws-token-sha256` is required when `--ws-auth capability-token` is set"
|
|
);
|
|
}
|
|
};
|
|
Some(AppServerWebsocketAuthConfig::CapabilityToken { source })
|
|
}
|
|
Some(WebsocketAuthCliMode::SignedBearerToken) => {
|
|
if self.ws_token_file.is_some() || self.ws_token_sha256.is_some() {
|
|
anyhow::bail!(
|
|
"`--ws-token-file` and `--ws-token-sha256` require `--ws-auth capability-token`, not `signed-bearer-token`"
|
|
);
|
|
}
|
|
let shared_secret_file = self.ws_shared_secret_file.context(
|
|
"`--ws-shared-secret-file` is required when `--ws-auth signed-bearer-token` is set",
|
|
)?;
|
|
Some(AppServerWebsocketAuthConfig::SignedBearerToken {
|
|
shared_secret_file: absolute_path_arg(
|
|
"--ws-shared-secret-file",
|
|
shared_secret_file,
|
|
)?,
|
|
issuer: normalize(self.ws_issuer),
|
|
audience: normalize(self.ws_audience),
|
|
max_clock_skew_seconds: self
|
|
.ws_max_clock_skew_seconds
|
|
.unwrap_or(DEFAULT_MAX_CLOCK_SKEW_SECONDS),
|
|
})
|
|
}
|
|
None => {
|
|
if self.ws_token_file.is_some()
|
|
|| self.ws_token_sha256.is_some()
|
|
|| self.ws_shared_secret_file.is_some()
|
|
|| self.ws_issuer.is_some()
|
|
|| self.ws_audience.is_some()
|
|
|| self.ws_max_clock_skew_seconds.is_some()
|
|
{
|
|
anyhow::bail!(
|
|
"websocket auth flags require `--ws-auth capability-token` or `--ws-auth signed-bearer-token`"
|
|
);
|
|
}
|
|
None
|
|
}
|
|
};
|
|
|
|
Ok(AppServerWebsocketAuthSettings { config })
|
|
}
|
|
}
|
|
|
|
pub fn policy_from_settings(
|
|
settings: &AppServerWebsocketAuthSettings,
|
|
) -> io::Result<WebsocketAuthPolicy> {
|
|
let mode = match settings.config.as_ref() {
|
|
Some(AppServerWebsocketAuthConfig::CapabilityToken { source }) => match source {
|
|
AppServerWebsocketCapabilityTokenSource::TokenFile { token_file } => {
|
|
let token = read_trimmed_secret(token_file.as_ref())?;
|
|
Some(WebsocketAuthMode::CapabilityToken {
|
|
token_sha256: sha256_digest(token.as_bytes()),
|
|
})
|
|
}
|
|
AppServerWebsocketCapabilityTokenSource::TokenSha256 { token_sha256 } => {
|
|
Some(WebsocketAuthMode::CapabilityToken {
|
|
token_sha256: *token_sha256,
|
|
})
|
|
}
|
|
},
|
|
Some(AppServerWebsocketAuthConfig::SignedBearerToken {
|
|
shared_secret_file,
|
|
issuer,
|
|
audience,
|
|
max_clock_skew_seconds,
|
|
}) => {
|
|
let shared_secret = read_trimmed_secret(shared_secret_file.as_ref())?.into_bytes();
|
|
validate_signed_bearer_secret(shared_secret_file.as_ref(), &shared_secret)?;
|
|
let max_clock_skew_seconds = i64::try_from(*max_clock_skew_seconds).map_err(|_| {
|
|
io::Error::new(
|
|
ErrorKind::InvalidInput,
|
|
"websocket auth clock skew must fit in a signed 64-bit integer",
|
|
)
|
|
})?;
|
|
Some(WebsocketAuthMode::SignedBearerToken {
|
|
shared_secret,
|
|
issuer: issuer.clone(),
|
|
audience: audience.clone(),
|
|
max_clock_skew_seconds,
|
|
})
|
|
}
|
|
None => None,
|
|
};
|
|
|
|
Ok(WebsocketAuthPolicy { mode })
|
|
}
|
|
|
|
pub(crate) fn is_unauthenticated_non_loopback_listener(
|
|
bind_address: SocketAddr,
|
|
policy: &WebsocketAuthPolicy,
|
|
) -> bool {
|
|
!bind_address.ip().is_loopback() && policy.mode.is_none()
|
|
}
|
|
|
|
pub(crate) fn authorize_upgrade(
|
|
headers: &HeaderMap,
|
|
policy: &WebsocketAuthPolicy,
|
|
) -> Result<(), WebsocketAuthError> {
|
|
let Some(mode) = policy.mode.as_ref() else {
|
|
return Ok(());
|
|
};
|
|
|
|
let token = bearer_token_from_headers(headers)?;
|
|
match mode {
|
|
WebsocketAuthMode::CapabilityToken { token_sha256 } => {
|
|
let actual_sha256 = sha256_digest(token.as_bytes());
|
|
if constant_time_eq_32(token_sha256, &actual_sha256) {
|
|
Ok(())
|
|
} else {
|
|
Err(unauthorized("invalid websocket bearer token"))
|
|
}
|
|
}
|
|
WebsocketAuthMode::SignedBearerToken {
|
|
shared_secret,
|
|
issuer,
|
|
audience,
|
|
max_clock_skew_seconds,
|
|
} => verify_signed_bearer_token(
|
|
token,
|
|
shared_secret,
|
|
issuer.as_deref(),
|
|
audience.as_deref(),
|
|
*max_clock_skew_seconds,
|
|
),
|
|
}
|
|
}
|
|
|
|
fn verify_signed_bearer_token(
|
|
token: &str,
|
|
shared_secret: &[u8],
|
|
issuer: Option<&str>,
|
|
audience: Option<&str>,
|
|
max_clock_skew_seconds: i64,
|
|
) -> Result<(), WebsocketAuthError> {
|
|
let claims = decode_jwt_claims(token, shared_secret)?;
|
|
validate_jwt_claims(&claims, issuer, audience, max_clock_skew_seconds)
|
|
}
|
|
|
|
fn decode_jwt_claims(token: &str, shared_secret: &[u8]) -> Result<JwtClaims, WebsocketAuthError> {
|
|
let mut validation = Validation::new(Algorithm::HS256);
|
|
validation.required_spec_claims.clear();
|
|
validation.validate_exp = false;
|
|
validation.validate_nbf = false;
|
|
validation.validate_aud = false;
|
|
|
|
decode::<JwtClaims>(token, &DecodingKey::from_secret(shared_secret), &validation)
|
|
.map(|token_data| token_data.claims)
|
|
.map_err(|_| unauthorized("invalid websocket jwt"))
|
|
}
|
|
|
|
fn validate_jwt_claims(
|
|
claims: &JwtClaims,
|
|
issuer: Option<&str>,
|
|
audience: Option<&str>,
|
|
max_clock_skew_seconds: i64,
|
|
) -> Result<(), WebsocketAuthError> {
|
|
let now = OffsetDateTime::now_utc().unix_timestamp();
|
|
if now > claims.exp.saturating_add(max_clock_skew_seconds) {
|
|
return Err(unauthorized("expired websocket jwt"));
|
|
}
|
|
if let Some(nbf) = claims.nbf
|
|
&& now < nbf.saturating_sub(max_clock_skew_seconds)
|
|
{
|
|
return Err(unauthorized("websocket jwt is not valid yet"));
|
|
}
|
|
if let Some(expected_issuer) = issuer
|
|
&& claims.iss.as_deref() != Some(expected_issuer)
|
|
{
|
|
return Err(unauthorized("websocket jwt issuer mismatch"));
|
|
}
|
|
if let Some(expected_audience) = audience
|
|
&& !audience_matches(claims.aud.as_ref(), expected_audience)
|
|
{
|
|
return Err(unauthorized("websocket jwt audience mismatch"));
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
fn audience_matches(audience: Option<&JwtAudienceClaim>, expected_audience: &str) -> bool {
|
|
match audience {
|
|
Some(JwtAudienceClaim::Single(actual)) => actual == expected_audience,
|
|
Some(JwtAudienceClaim::Multiple(actual)) => {
|
|
actual.iter().any(|audience| audience == expected_audience)
|
|
}
|
|
None => false,
|
|
}
|
|
}
|
|
|
|
fn bearer_token_from_headers(headers: &HeaderMap) -> Result<&str, WebsocketAuthError> {
|
|
let raw_header = headers
|
|
.get(AUTHORIZATION)
|
|
.ok_or_else(|| unauthorized("missing websocket bearer token"))?;
|
|
let header = raw_header
|
|
.to_str()
|
|
.map_err(|_| unauthorized(INVALID_AUTHORIZATION_HEADER_MESSAGE))?;
|
|
let Some((scheme, token)) = header.split_once(' ') else {
|
|
return Err(unauthorized(INVALID_AUTHORIZATION_HEADER_MESSAGE));
|
|
};
|
|
if !scheme.eq_ignore_ascii_case("Bearer") {
|
|
return Err(unauthorized(INVALID_AUTHORIZATION_HEADER_MESSAGE));
|
|
}
|
|
let token = token.trim();
|
|
if token.is_empty() {
|
|
return Err(unauthorized(INVALID_AUTHORIZATION_HEADER_MESSAGE));
|
|
}
|
|
Ok(token)
|
|
}
|
|
|
|
fn validate_signed_bearer_secret(path: &Path, shared_secret: &[u8]) -> io::Result<()> {
|
|
if shared_secret.len() < MIN_SIGNED_BEARER_SECRET_BYTES {
|
|
return Err(io::Error::new(
|
|
ErrorKind::InvalidInput,
|
|
format!(
|
|
"signed websocket bearer secret {} must be at least {MIN_SIGNED_BEARER_SECRET_BYTES} bytes",
|
|
path.display()
|
|
),
|
|
));
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn read_trimmed_secret(path: &std::path::Path) -> io::Result<String> {
|
|
let raw = std::fs::read_to_string(path).map_err(|err| {
|
|
io::Error::new(
|
|
err.kind(),
|
|
format!(
|
|
"failed to read websocket auth secret {}: {err}",
|
|
path.display()
|
|
),
|
|
)
|
|
})?;
|
|
let trimmed = raw.trim();
|
|
if trimmed.is_empty() {
|
|
return Err(io::Error::new(
|
|
ErrorKind::InvalidInput,
|
|
format!("websocket auth secret {} must not be empty", path.display()),
|
|
));
|
|
}
|
|
Ok(trimmed.to_string())
|
|
}
|
|
|
|
fn absolute_path_arg(flag_name: &str, path: PathBuf) -> anyhow::Result<AbsolutePathBuf> {
|
|
AbsolutePathBuf::try_from(path).with_context(|| format!("{flag_name} must be an absolute path"))
|
|
}
|
|
|
|
fn sha256_digest_arg(flag_name: &str, value: &str) -> anyhow::Result<[u8; 32]> {
|
|
let trimmed = value.trim();
|
|
if trimmed.len() != 64 {
|
|
anyhow::bail!("{flag_name} must be a 64-character hex SHA-256 digest");
|
|
}
|
|
|
|
let mut digest = [0u8; 32];
|
|
for (index, pair) in trimmed.as_bytes().chunks_exact(2).enumerate() {
|
|
let high = hex_nibble(flag_name, pair[0])?;
|
|
let low = hex_nibble(flag_name, pair[1])?;
|
|
digest[index] = (high << 4) | low;
|
|
}
|
|
Ok(digest)
|
|
}
|
|
|
|
fn hex_nibble(flag_name: &str, byte: u8) -> anyhow::Result<u8> {
|
|
match byte {
|
|
b'0'..=b'9' => Ok(byte - b'0'),
|
|
b'a'..=b'f' => Ok(byte - b'a' + 10),
|
|
b'A'..=b'F' => Ok(byte - b'A' + 10),
|
|
_ => anyhow::bail!("{flag_name} must be a 64-character hex SHA-256 digest"),
|
|
}
|
|
}
|
|
|
|
fn sha256_digest(input: &[u8]) -> [u8; 32] {
|
|
let mut digest = [0u8; 32];
|
|
digest.copy_from_slice(&Sha256::digest(input));
|
|
digest
|
|
}
|
|
|
|
fn unauthorized(message: &'static str) -> WebsocketAuthError {
|
|
WebsocketAuthError {
|
|
status_code: StatusCode::UNAUTHORIZED,
|
|
message,
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use axum::http::HeaderValue;
|
|
use base64::Engine;
|
|
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
|
|
use hmac::Hmac;
|
|
use hmac::Mac;
|
|
use serde_json::json;
|
|
|
|
type HmacSha256 = Hmac<Sha256>;
|
|
|
|
fn signed_token(shared_secret: &[u8], claims: serde_json::Value) -> String {
|
|
let header = URL_SAFE_NO_PAD.encode(br#"{"alg":"HS256","typ":"JWT"}"#);
|
|
let claims_segment = URL_SAFE_NO_PAD.encode(serde_json::to_vec(&claims).unwrap());
|
|
let payload = format!("{header}.{claims_segment}");
|
|
let mut mac = HmacSha256::new_from_slice(shared_secret).unwrap();
|
|
mac.update(payload.as_bytes());
|
|
let signature = URL_SAFE_NO_PAD.encode(mac.finalize().into_bytes());
|
|
format!("{payload}.{signature}")
|
|
}
|
|
|
|
#[test]
|
|
fn detects_unauthenticated_non_loopback_listener() {
|
|
let policy = WebsocketAuthPolicy::default();
|
|
assert!(is_unauthenticated_non_loopback_listener(
|
|
"0.0.0.0:8765".parse().unwrap(),
|
|
&policy,
|
|
));
|
|
assert!(!is_unauthenticated_non_loopback_listener(
|
|
"127.0.0.1:8765".parse().unwrap(),
|
|
&policy,
|
|
));
|
|
assert!(!is_unauthenticated_non_loopback_listener(
|
|
"0.0.0.0:8765".parse().unwrap(),
|
|
&WebsocketAuthPolicy {
|
|
mode: Some(WebsocketAuthMode::CapabilityToken {
|
|
token_sha256: [0u8; 32],
|
|
}),
|
|
},
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn capability_token_args_require_token_file_or_hash() {
|
|
let err = AppServerWebsocketAuthArgs {
|
|
ws_auth: Some(WebsocketAuthCliMode::CapabilityToken),
|
|
..Default::default()
|
|
}
|
|
.try_into_settings()
|
|
.expect_err("capability-token mode should require a token source");
|
|
assert!(
|
|
err.to_string().contains("--ws-token-file")
|
|
&& err.to_string().contains("--ws-token-sha256"),
|
|
"unexpected error: {err}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn capability_token_args_accept_token_hash() {
|
|
let settings = AppServerWebsocketAuthArgs {
|
|
ws_auth: Some(WebsocketAuthCliMode::CapabilityToken),
|
|
ws_token_sha256: Some("ab".repeat(32)),
|
|
..Default::default()
|
|
}
|
|
.try_into_settings()
|
|
.expect("capability-token hash args should parse");
|
|
|
|
assert_eq!(
|
|
settings,
|
|
AppServerWebsocketAuthSettings {
|
|
config: Some(AppServerWebsocketAuthConfig::CapabilityToken {
|
|
source: AppServerWebsocketCapabilityTokenSource::TokenSha256 {
|
|
token_sha256: [0xab; 32],
|
|
},
|
|
}),
|
|
}
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn capability_token_args_reject_multiple_token_sources() {
|
|
let err = AppServerWebsocketAuthArgs {
|
|
ws_auth: Some(WebsocketAuthCliMode::CapabilityToken),
|
|
ws_token_file: Some(PathBuf::from("/tmp/token")),
|
|
ws_token_sha256: Some("ab".repeat(32)),
|
|
..Default::default()
|
|
}
|
|
.try_into_settings()
|
|
.expect_err("capability-token mode should reject multiple token sources");
|
|
assert!(
|
|
err.to_string().contains("mutually exclusive"),
|
|
"unexpected error: {err}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn capability_token_args_reject_malformed_token_hash() {
|
|
let err = AppServerWebsocketAuthArgs {
|
|
ws_auth: Some(WebsocketAuthCliMode::CapabilityToken),
|
|
ws_token_sha256: Some("not-a-sha256".to_string()),
|
|
..Default::default()
|
|
}
|
|
.try_into_settings()
|
|
.expect_err("capability-token mode should reject malformed token hashes");
|
|
assert!(
|
|
err.to_string().contains("64-character hex"),
|
|
"unexpected error: {err}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn capability_token_hash_policy_authorizes_matching_bearer_token() {
|
|
let settings = AppServerWebsocketAuthSettings {
|
|
config: Some(AppServerWebsocketAuthConfig::CapabilityToken {
|
|
source: AppServerWebsocketCapabilityTokenSource::TokenSha256 {
|
|
token_sha256: sha256_digest(b"super-secret-token"),
|
|
},
|
|
}),
|
|
};
|
|
let policy = policy_from_settings(&settings).expect("hash policy should build");
|
|
let mut headers = HeaderMap::new();
|
|
headers.insert(
|
|
AUTHORIZATION,
|
|
HeaderValue::from_static("Bearer super-secret-token"),
|
|
);
|
|
authorize_upgrade(&headers, &policy).expect("matching token should authorize");
|
|
|
|
headers.insert(
|
|
AUTHORIZATION,
|
|
HeaderValue::from_static("Bearer wrong-token"),
|
|
);
|
|
let err = authorize_upgrade(&headers, &policy).expect_err("wrong token should fail");
|
|
assert_eq!(err.status_code(), StatusCode::UNAUTHORIZED);
|
|
}
|
|
|
|
#[test]
|
|
fn signed_bearer_args_require_mode_when_mode_specific_flags_are_set() {
|
|
let err = AppServerWebsocketAuthArgs {
|
|
ws_shared_secret_file: Some(PathBuf::from("/tmp/secret")),
|
|
..Default::default()
|
|
}
|
|
.try_into_settings()
|
|
.expect_err("mode-specific flags should require --ws-auth");
|
|
assert!(
|
|
err.to_string().contains("websocket auth flags require"),
|
|
"unexpected error: {err}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn signed_bearer_args_default_clock_skew_and_trim_optional_claims() {
|
|
let settings = AppServerWebsocketAuthArgs {
|
|
ws_auth: Some(WebsocketAuthCliMode::SignedBearerToken),
|
|
ws_shared_secret_file: Some(PathBuf::from("/tmp/secret")),
|
|
ws_issuer: Some(" issuer ".to_string()),
|
|
ws_audience: Some(" ".to_string()),
|
|
..Default::default()
|
|
}
|
|
.try_into_settings()
|
|
.expect("signed bearer args should parse");
|
|
|
|
assert_eq!(
|
|
settings,
|
|
AppServerWebsocketAuthSettings {
|
|
config: Some(AppServerWebsocketAuthConfig::SignedBearerToken {
|
|
shared_secret_file: AbsolutePathBuf::from_absolute_path("/tmp/secret")
|
|
.expect("absolute path"),
|
|
issuer: Some("issuer".to_string()),
|
|
audience: None,
|
|
max_clock_skew_seconds: DEFAULT_MAX_CLOCK_SKEW_SECONDS,
|
|
}),
|
|
}
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn signed_bearer_token_verification_rejects_tampering() {
|
|
let shared_secret = b"0123456789abcdef0123456789abcdef";
|
|
let token = signed_token(
|
|
shared_secret,
|
|
json!({
|
|
"exp": OffsetDateTime::now_utc().unix_timestamp() + 60,
|
|
}),
|
|
);
|
|
let tampered = token.replace(".eyJleHAi", ".eyJleHBi");
|
|
let err = verify_signed_bearer_token(
|
|
&tampered,
|
|
shared_secret,
|
|
/*issuer*/ None,
|
|
/*audience*/ None,
|
|
/*max_clock_skew_seconds*/ 30,
|
|
)
|
|
.expect_err("tampered jwt should fail");
|
|
assert_eq!(err.status_code(), StatusCode::UNAUTHORIZED);
|
|
}
|
|
|
|
#[test]
|
|
fn signed_bearer_token_verification_accepts_valid_token() {
|
|
let shared_secret = b"0123456789abcdef0123456789abcdef";
|
|
let token = signed_token(
|
|
shared_secret,
|
|
json!({
|
|
"exp": OffsetDateTime::now_utc().unix_timestamp() + 60,
|
|
"iss": "issuer",
|
|
"aud": "audience",
|
|
}),
|
|
);
|
|
verify_signed_bearer_token(
|
|
&token,
|
|
shared_secret,
|
|
Some("issuer"),
|
|
Some("audience"),
|
|
/*max_clock_skew_seconds*/ 30,
|
|
)
|
|
.expect("valid signed token should verify");
|
|
}
|
|
|
|
#[test]
|
|
fn signed_bearer_token_verification_accepts_multiple_audiences() {
|
|
let shared_secret = b"0123456789abcdef0123456789abcdef";
|
|
let token = signed_token(
|
|
shared_secret,
|
|
json!({
|
|
"exp": OffsetDateTime::now_utc().unix_timestamp() + 60,
|
|
"aud": ["other-audience", "audience"],
|
|
}),
|
|
);
|
|
verify_signed_bearer_token(
|
|
&token,
|
|
shared_secret,
|
|
/*issuer*/ None,
|
|
Some("audience"),
|
|
/*max_clock_skew_seconds*/ 30,
|
|
)
|
|
.expect("jwt audience arrays should verify");
|
|
}
|
|
|
|
#[test]
|
|
fn signed_bearer_token_verification_rejects_alg_none_tokens() {
|
|
let claims_segment = URL_SAFE_NO_PAD.encode(
|
|
serde_json::to_vec(&json!({
|
|
"exp": OffsetDateTime::now_utc().unix_timestamp() + 60,
|
|
}))
|
|
.unwrap(),
|
|
);
|
|
let header_segment = URL_SAFE_NO_PAD.encode(br#"{"alg":"none","typ":"JWT"}"#);
|
|
let token = format!("{header_segment}.{claims_segment}.");
|
|
let err = verify_signed_bearer_token(
|
|
&token,
|
|
b"0123456789abcdef0123456789abcdef",
|
|
/*issuer*/ None,
|
|
/*audience*/ None,
|
|
/*max_clock_skew_seconds*/ 30,
|
|
)
|
|
.expect_err("alg=none jwt should be rejected");
|
|
assert_eq!(err.status_code(), StatusCode::UNAUTHORIZED);
|
|
}
|
|
|
|
#[test]
|
|
fn signed_bearer_token_verification_rejects_missing_exp() {
|
|
let shared_secret = b"0123456789abcdef0123456789abcdef";
|
|
let token = signed_token(
|
|
shared_secret,
|
|
json!({
|
|
"iss": "issuer",
|
|
}),
|
|
);
|
|
let err = verify_signed_bearer_token(
|
|
&token,
|
|
shared_secret,
|
|
/*issuer*/ None,
|
|
/*audience*/ None,
|
|
/*max_clock_skew_seconds*/ 30,
|
|
)
|
|
.expect_err("jwt without exp should be rejected");
|
|
assert_eq!(err.status_code(), StatusCode::UNAUTHORIZED);
|
|
}
|
|
|
|
#[test]
|
|
fn validate_signed_bearer_secret_rejects_short_secret() {
|
|
let err = validate_signed_bearer_secret(Path::new("/tmp/secret"), b"too-short")
|
|
.expect_err("short shared secret should be rejected");
|
|
assert_eq!(err.kind(), ErrorKind::InvalidInput);
|
|
assert!(
|
|
err.to_string().contains("must be at least 32 bytes"),
|
|
"unexpected error: {err}"
|
|
);
|
|
}
|
|
}
|