Codex Handbook
app-server-transport/src/transport/auth.rs 751 lines
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}"        );    }}