Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -30,19 +30,21 @@ impl std::fmt::Debug for ResolvedCredential {
#[derive(Debug, Clone, Default)]
pub(in super::super) struct RequestCredentials {
pub(in super::super) api_key: Option<String>,
pub(in super::super) api_key_present: bool,
pub(in super::super) authorization_present: bool,
pub(in super::super) bearer_token: Option<String>,
pub(in super::super) id_token: Option<String>,
}

impl RequestCredentials {
pub(in super::super) fn credential_type_count(&self) -> usize {
usize::from(self.api_key.is_some())
usize::from(self.api_key_present || self.api_key.is_some())
+ usize::from(self.authorization_present || self.bearer_token.is_some())
}

pub(in super::super) fn are_absent(&self) -> bool {
self.api_key.is_none()
&& !self.api_key_present
&& !self.authorization_present
&& self.bearer_token.is_none()
&& self.id_token.is_none()
Expand Down Expand Up @@ -105,22 +107,19 @@ pub(in super::super) fn resolve_credentials(
}

pub(in super::super) fn request_credentials(request: &Request) -> RequestCredentials {
let authorization = request
.headers()
.get(header::AUTHORIZATION)
.and_then(header_str);
let headers = request.headers();
let authorization = headers.get(header::AUTHORIZATION).and_then(header_str);
RequestCredentials {
api_key: request
.headers()
api_key: headers
.get("x-api-key")
.and_then(header_str)
.map(ToOwned::to_owned),
authorization_present: authorization.is_some(),
api_key_present: headers.contains_key("x-api-key"),
authorization_present: headers.contains_key(header::AUTHORIZATION),
bearer_token: authorization
.and_then(|raw| parse_bearer_token(raw).ok())
.map(ToOwned::to_owned),
id_token: request
.headers()
id_token: headers
.get(OIDC_ID_TOKEN_HEADER)
.and_then(header_str)
.map(ToOwned::to_owned),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -419,6 +419,7 @@ impl OfflineNotaryHarness {
};
let principal = authenticate_api_key(
&RequestCredentials {
api_key_present: api_key.is_some(),
api_key,
authorization_present: false,
bearer_token: None,
Expand Down
81 changes: 81 additions & 0 deletions crates/registry-notary-server/src/standalone/tests/auth.inc
Original file line number Diff line number Diff line change
Expand Up @@ -355,6 +355,7 @@
};
let request = RequestCredentials {
api_key: Some("api-token".to_string()),
api_key_present: true,
authorization_present: true,
bearer_token: Some("bearer-token".to_string()),
id_token: None,
Expand Down Expand Up @@ -382,6 +383,7 @@
};
let request = RequestCredentials {
api_key: Some("api-token".to_string()),
api_key_present: true,
authorization_present: true,
bearer_token: None,
id_token: None,
Expand All @@ -395,22 +397,97 @@
assert!(matches!(err, EvidenceError::MultipleCredentials));
}

#[tokio::test]
async fn request_credentials_counts_non_utf8_authorization_header_presence() {
let authenticator = Authenticator {
api_keys: vec![ResolvedCredential {
id: "api-client".to_string(),
fingerprint: registry_platform_authcommon::fingerprint_api_key("api-token"),
scopes: vec!["farmer_registry:evidence_verification".to_string()],
authorization_details: None,
}],
bearer_tokens: Vec::new(),
oidc: None,
};
let mut request = Request::builder()
.uri("/v1/claims")
.header("x-api-key", "api-token")
.body(Body::empty())
.expect("request builds");
request.headers_mut().insert(
header::AUTHORIZATION,
axum::http::HeaderValue::from_bytes(b"\xff").expect("opaque header value is valid"),
);

let credentials = request_credentials(&request);
assert!(credentials.authorization_present);
assert!(credentials.api_key_present);
assert!(credentials.bearer_token.is_none());
assert_eq!(credentials.api_key.as_deref(), Some("api-token"));
assert_eq!(credentials.credential_type_count(), 2);

let err = authenticator
.authenticate(credentials, &ReplayStores::memory())
.await
.expect_err("raw credential channel presence must reject ambiguity");
assert!(matches!(err, EvidenceError::MultipleCredentials));
}

#[tokio::test]
async fn request_credentials_counts_non_utf8_api_key_header_presence() {
let authenticator = Authenticator {
api_keys: Vec::new(),
bearer_tokens: vec![ResolvedCredential {
id: "bearer-client".to_string(),
fingerprint: registry_platform_authcommon::fingerprint_api_key("bearer-token"),
scopes: vec!["farmer_registry:evidence_verification".to_string()],
authorization_details: None,
}],
oidc: None,
};
let mut request = Request::builder()
.uri("/v1/claims")
.header(header::AUTHORIZATION, "Bearer bearer-token")
.body(Body::empty())
.expect("request builds");
request.headers_mut().insert(
"x-api-key",
axum::http::HeaderValue::from_bytes(b"\xff").expect("opaque header value is valid"),
);

let credentials = request_credentials(&request);
assert!(credentials.api_key_present);
assert!(credentials.api_key.is_none());
assert!(credentials.authorization_present);
assert_eq!(credentials.bearer_token.as_deref(), Some("bearer-token"));
assert_eq!(credentials.credential_type_count(), 2);

let err = authenticator
.authenticate(credentials, &ReplayStores::memory())
.await
.expect_err("raw credential channel presence must reject ambiguity");
assert!(matches!(err, EvidenceError::MultipleCredentials));
}

#[test]
fn oidc_id_token_is_supplemental_not_a_separate_auth_mode() {
let oidc_request = RequestCredentials {
api_key: None,
api_key_present: false,
authorization_present: true,
bearer_token: Some("access-token".to_string()),
id_token: Some("id-token".to_string()),
};
let api_key_and_bearer = RequestCredentials {
api_key: Some("api-token".to_string()),
api_key_present: true,
authorization_present: true,
bearer_token: Some("bearer-token".to_string()),
id_token: None,
};
let api_key_and_malformed_authorization = RequestCredentials {
api_key: Some("api-token".to_string()),
api_key_present: true,
authorization_present: true,
bearer_token: None,
id_token: None,
Expand All @@ -434,6 +511,7 @@
};
let request = RequestCredentials {
api_key: Some("api-token".to_string()),
api_key_present: true,
authorization_present: false,
bearer_token: None,
id_token: None,
Expand Down Expand Up @@ -474,6 +552,7 @@
};
let request = RequestCredentials {
api_key: Some("api-token".to_string()),
api_key_present: true,
authorization_present: false,
bearer_token: None,
id_token: None,
Expand Down Expand Up @@ -942,6 +1021,7 @@
};
let request = RequestCredentials {
api_key: Some("api-token".to_string()),
api_key_present: true,
authorization_present: false,
bearer_token: None,
id_token: None,
Expand All @@ -952,6 +1032,7 @@
let error = authenticate_static(
&RequestCredentials {
api_key: Some(rejected_secret.to_string()),
api_key_present: true,
authorization_present: false,
bearer_token: None,
id_token: None,
Expand Down
101 changes: 101 additions & 0 deletions crates/registry-notary-server/tests/standalone_http/auth.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,107 @@ use super::{
admin::*, audit::*, credentials::*, federation::*, http_contracts::*, oid4vci::*, preauth::*,
};

#[tokio::test]
pub(super) async fn malformed_authorization_with_valid_api_key_is_candidate_neutral() {
assert_ambiguous_primary_headers_are_candidate_neutral(
axum::http::HeaderValue::from_bytes(b"\xff").expect("opaque header value is valid"),
axum::http::HeaderValue::from_static("api-token"),
)
.await;
}

#[tokio::test]
pub(super) async fn valid_authorization_with_non_utf8_api_key_is_candidate_neutral() {
assert_ambiguous_primary_headers_are_candidate_neutral(
axum::http::HeaderValue::from_static("Bearer api-token"),
axum::http::HeaderValue::from_bytes(b"\xff").expect("opaque header value is valid"),
)
.await;
}

async fn assert_ambiguous_primary_headers_are_candidate_neutral(
authorization: axum::http::HeaderValue,
api_key: axum::http::HeaderValue,
) {
const API_KEY_MARKER: &str = "api-token";
const PRINCIPAL_MARKER: &str = "caseworker";
const BEARER_PRINCIPAL_MARKER: &str = "bearer-caseworker";
const SCOPE_MARKER: &str = "farmer_registry:evidence_verification";

std::env::set_var(
"TEST_EVIDENCE_API_KEY_HASH",
"sha256:a00cf33cd46d9ef96c1eff33df1c9cca20b1a02468cd78ec6a4b2887d1640b51",
);
let tmp = TempDir::new().expect("tempdir");
let audit_path = tmp.path().join("audit.jsonl");
let mut config = notary_only_config(
"http://127.0.0.1:1",
audit_path.to_str().expect("audit path is UTF-8"),
);
config.auth.bearer_tokens.push(EvidenceCredentialConfig {
id: BEARER_PRINCIPAL_MARKER.to_string(),
fingerprint: env_fingerprint_ref("TEST_EVIDENCE_API_KEY_HASH"),
scopes: vec![SCOPE_MARKER.to_string()],
authorization_details: None,
});
let app = standalone_router(config)
.await
.expect("standalone router builds");
let server = TestServer::builder().http_transport().build(app);

let response = server
.get("/v1/claims")
.add_header("x-api-key", api_key)
.add_header("authorization", authorization)
.await;

response.assert_status(StatusCode::BAD_REQUEST);
let problem: Value = response.json();
assert_eq!(problem["code"], json!("auth.multiple_credentials"));
assert_eq!(
problem["detail"],
json!("provide exactly one authentication credential")
);
assert!(problem.get("data").is_none());
let rendered = problem.to_string();
for marker in [
API_KEY_MARKER,
PRINCIPAL_MARKER,
BEARER_PRINCIPAL_MARKER,
SCOPE_MARKER,
] {
assert!(
!rendered.contains(marker),
"problem body leaked marker {marker}"
);
}

let audit = std::fs::read_to_string(&audit_path).expect("audit was written");
for marker in [
API_KEY_MARKER,
PRINCIPAL_MARKER,
BEARER_PRINCIPAL_MARKER,
SCOPE_MARKER,
] {
assert!(!audit.contains(marker), "audit leaked marker {marker}");
}
let records = audit_envelopes(&audit_path)
.into_iter()
.map(|envelope| envelope.record)
.filter(|record| record["path"] == json!("/v1/claims"))
.collect::<Vec<_>>();
assert_eq!(records.len(), 1, "one request must emit one audit record");
let record = &records[0];
assert_eq!(record["status"], json!(400));
assert_eq!(record["decision"], json!("denied"));
assert_eq!(record["error_code"], json!("auth.multiple_credentials"));
assert!(record.get("principal_id").is_none());
assert!(record.get("principal_id_hash").is_none());
assert_eq!(record["scopes_used"], json!([]));
assert!(record.get("relay_consultation_count").is_none());
assert!(record.get("relay_consultation_ids").is_none());
}

#[tokio::test]
pub(super) async fn additive_api_key_and_oidc_authenticate_on_the_same_router() {
set_audit_secret();
Expand Down
Loading
Loading