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
55 changes: 55 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions crates/rest/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,7 @@ hmac = "0.12" # A*CBC-HS* authenticat
sha2 = "0.10" # Concat KDF, A*CBC-HS*
p256 = { version = "0.13", features = ["ecdh", "jwk", "pem", "pkcs8"] } # ECDH-ES (P-256)
p384 = { version = "0.13", features = ["ecdh", "jwk", "pem", "pkcs8"] } # ECDH-ES (P-384)
rsa = { version = "0.9", features = ["pem"] } # RS384 public JWK derivation (#529)
flate2 = "1" # JWE `zip: "DEF"` payloads

[dev-dependencies]
Expand Down
63 changes: 63 additions & 0 deletions crates/rest/src/bulk_submit_oauth.rs
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ pub struct JwtClientCredentialsTokenProvider {
client_id: String,
signing_alg: Algorithm,
encoding_key: EncodingKey,
/// RFC 7638 thumbprint of the public key, used as `kid` in assertion headers.
kid: Option<String>,
/// Cache of `(token_endpoint, scope) -> (token, expiry)`.
cache: Mutex<HashMap<(String, String), (String, Instant)>>,
}
Expand All @@ -45,11 +47,14 @@ impl JwtClientCredentialsTokenProvider {
),
_ => return None,
};
let kid = derive_public_jwk(private_key_pem, signing_alg)
.and_then(|jwk| jwk.get("kid").and_then(|v| v.as_str()).map(String::from));
Some(Arc::new(Self {
client: reqwest::Client::new(),
client_id: client_id.to_string(),
signing_alg: alg,
encoding_key: key,
kid,
cache: Mutex::new(HashMap::new()),
}))
}
Expand Down Expand Up @@ -79,6 +84,7 @@ impl JwtClientCredentialsTokenProvider {
});
let mut header = Header::new(self.signing_alg);
header.typ = Some("JWT".to_string());
header.kid = self.kid.clone();
jsonwebtoken::encode(&header, &claims, &self.encoding_key).ok()
}

Expand Down Expand Up @@ -144,6 +150,63 @@ impl FileTokenProvider for JwtClientCredentialsTokenProvider {
}
}

/// Derives the public JWK (with RFC 7638 thumbprint as `kid`) from a PEM private key.
///
/// Supports ES384 (P-384) and RS384. Returns `None` for any other algorithm or
/// for a key that cannot be parsed.
pub(crate) fn derive_public_jwk(pem: &str, alg: &str) -> Option<Value> {
use base64::Engine as _;
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use sha2::{Digest, Sha256};

match alg {
"ES384" => {
use p384::elliptic_curve::sec1::ToEncodedPoint;
use p384::pkcs8::DecodePrivateKey;

let secret = p384::SecretKey::from_pkcs8_pem(pem)
.or_else(|_| p384::SecretKey::from_sec1_pem(pem))
.ok()?;
let point = secret.public_key().to_encoded_point(false);
let x = URL_SAFE_NO_PAD.encode(point.x()?);
let y = URL_SAFE_NO_PAD.encode(point.y()?);
// RFC 7638 §3.3: required EC members in lexicographic order.
let canonical = format!(r#"{{"crv":"P-384","kty":"EC","x":"{x}","y":"{y}"}}"#);
let kid = URL_SAFE_NO_PAD.encode(Sha256::digest(canonical.as_bytes()));
Some(serde_json::json!({
"kty": "EC",
"crv": "P-384",
"x": x,
"y": y,
"kid": kid,
"use": "sig",
"alg": "ES384",
}))
}
"RS384" => {
use rsa::pkcs8::DecodePrivateKey;
use rsa::traits::PublicKeyParts;

let private_key = rsa::RsaPrivateKey::from_pkcs8_pem(pem).ok()?;
let pub_key = private_key.to_public_key();
let n = URL_SAFE_NO_PAD.encode(pub_key.n().to_bytes_be());
let e = URL_SAFE_NO_PAD.encode(pub_key.e().to_bytes_be());
// RFC 7638 §3.3: required RSA members in lexicographic order.
let canonical = format!(r#"{{"e":"{e}","kty":"RSA","n":"{n}"}}"#);
let kid = URL_SAFE_NO_PAD.encode(Sha256::digest(canonical.as_bytes()));
Some(serde_json::json!({
"kty": "RSA",
"n": n,
"e": e,
"kid": kid,
"use": "sig",
"alg": "RS384",
}))
}
_ => None,
}
}

#[cfg(test)]
mod tests {
use super::*;
Expand Down
30 changes: 30 additions & 0 deletions crates/rest/src/handlers/bulk_submit_jwks.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
//! JWKS endpoint for HFS's own bulk-submit signing key.
//!
//! Serves `/.well-known/bulk-submit-jwks.json` — the public key HFS uses when
//! signing outbound SMART Backend Services client assertions. Recipients register
//! this URL once instead of hand-carrying a PEM on every key rotation.

use axum::{Json, extract::State, response::IntoResponse};
use helios_persistence::core::ResourceStorage;

use crate::bulk_submit_oauth::derive_public_jwk;
use crate::state::AppState;

/// `GET /.well-known/bulk-submit-jwks.json`
///
/// Returns a JWK Set containing the server's current signing public key.
/// Responds with an empty `keys` array when no signing key is configured or
/// when the configured algorithm is not ES384.
pub async fn bulk_submit_jwks_handler<S>(State(state): State<AppState<S>>) -> impl IntoResponse
where
S: ResourceStorage + Send + Sync,
{
let config = state.bulk_submit_config();
let keys = config
.private_key
.as_deref()
.and_then(|pem| derive_public_jwk(pem, &config.signing_alg))
.into_iter()
.collect::<Vec<_>>();
Json(serde_json::json!({ "keys": keys }))
}
2 changes: 2 additions & 0 deletions crates/rest/src/handlers/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ pub mod batch;
pub mod bulk_common;
pub mod bulk_export;
pub mod bulk_submit;
pub mod bulk_submit_jwks;
pub mod capabilities;
pub mod compartment;
pub mod console_metrics;
Expand Down Expand Up @@ -71,6 +72,7 @@ pub use bulk_submit::{
bulk_submit_cancel_handler, bulk_submit_file_handler, bulk_submit_kickoff_handler,
bulk_submit_poll_handler, bulk_submit_status_kickoff_handler,
};
pub use bulk_submit_jwks::bulk_submit_jwks_handler;
pub use capabilities::capabilities_handler;
pub use compartment::compartment_search_handler;
pub use create::create_handler;
Expand Down
1 change: 1 addition & 0 deletions crates/rest/src/middleware/auth.rs
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ const EXEMPT_PATHS: &[&str] = &[
"/_liveness",
"/_readiness",
"/.well-known/smart-configuration",
"/.well-known/bulk-submit-jwks.json",
"/$versions",
];

Expand Down
4 changes: 4 additions & 0 deletions crates/rest/src/routing/fhir_routes.rs
Original file line number Diff line number Diff line change
Expand Up @@ -232,6 +232,10 @@ where
"/.well-known/smart-configuration",
get(handlers::smart_discovery::smart_configuration_handler::<S>),
)
.route(
"/.well-known/bulk-submit-jwks.json",
get(handlers::bulk_submit_jwks_handler::<S>),
)
.route("/_history", get(handlers::history_system_handler::<S>))
// Per-user UI settings. The leading `_` keeps these authenticated yet
// exempt from FHIR scope checks, and out of the FHIR resource namespace.
Expand Down
5 changes: 5 additions & 0 deletions crates/ui/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,11 @@ chrono.workspace = true
# Bulk Import workspace (#527).
uuid = { version = "1", features = ["v4"] }
jsonwebtoken = "9"
# RFC 7638 thumbprint derivation for the bulk-submit signing key kid (#529).
p384 = { version = "0.13", features = ["pem", "pkcs8"] }
rsa = { version = "0.9", features = ["pem"] }
sha2 = "0.10"
base64 = "0.22"

# Compile-time, type-checked, auto-escaping templates (Jinja2-like).
# Markup lives in templates/, never in Rust source.
Expand Down
59 changes: 43 additions & 16 deletions crates/ui/src/bulk_import.rs
Original file line number Diff line number Diff line change
Expand Up @@ -574,6 +574,41 @@ fn url_origin(url: &str) -> String {
}
}

/// Computes the RFC 7638 thumbprint of a private key as the `kid`.
/// Supports ES384 (P-384) and RS384. Returns `None` when the PEM cannot be parsed.
fn signing_kid(pem: &str, alg: &str) -> Option<String> {
use base64::Engine as _;
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use sha2::{Digest, Sha256};

match alg {
"RS384" => {
use rsa::pkcs8::DecodePrivateKey;
use rsa::traits::PublicKeyParts;

let private_key = rsa::RsaPrivateKey::from_pkcs8_pem(pem).ok()?;
let pub_key = private_key.to_public_key();
let n = URL_SAFE_NO_PAD.encode(pub_key.n().to_bytes_be());
let e = URL_SAFE_NO_PAD.encode(pub_key.e().to_bytes_be());
let canonical = format!(r#"{{"e":"{e}","kty":"RSA","n":"{n}"}}"#);
Some(URL_SAFE_NO_PAD.encode(Sha256::digest(canonical.as_bytes())))
}
_ => {
use p384::elliptic_curve::sec1::ToEncodedPoint;
use p384::pkcs8::DecodePrivateKey;

let secret = p384::SecretKey::from_pkcs8_pem(pem)
.or_else(|_| p384::SecretKey::from_sec1_pem(pem))
.ok()?;
let point = secret.public_key().to_encoded_point(false);
let x = URL_SAFE_NO_PAD.encode(point.x()?);
let y = URL_SAFE_NO_PAD.encode(point.y()?);
let canonical = format!(r#"{{"crv":"P-384","kty":"EC","x":"{x}","y":"{y}"}}"#);
Some(URL_SAFE_NO_PAD.encode(Sha256::digest(canonical.as_bytes())))
}
}
}

/// Mints a SMART Backend Services access token (`client_credentials` +
/// `private_key_jwt`) against the submission's token endpoint. The signing key
/// is the server-wide `HFS_BULK_SUBMIT_PRIVATE_KEY`, shared with the consumer
Expand Down Expand Up @@ -605,11 +640,7 @@ async fn backend_services_token(client_id: &str, token_url: &str) -> Result<Stri
"jti": uuid::Uuid::new_v4().to_string(),
});
let mut header = Header::new(algorithm);
// SMART Backend Services requires `kid` in the assertion header; it must
// match the key's id in the JWKS registered with the recipient.
if let Ok(kid) = std::env::var("HFS_BULK_SUBMIT_KID") {
header.kid = Some(kid);
}
header.kid = signing_kid(&pem, &alg);
let assertion = encode(&header, &claims, &key).map_err(|e| e.to_string())?;

let response = reqwest::Client::new()
Expand Down Expand Up @@ -963,18 +994,14 @@ pub async fn status_fragment(
})
}

/// `GET /ui/bulk-import/keys` — this data provider's JWKS, for registration
/// with recipients. Serves the JWK configured in
/// `HFS_BULK_SUBMIT_PUBLIC_JWK` verbatim (the public half of the signing
/// key), mirroring the reference provider's /keys endpoint. 404 when unset.
/// `GET /ui/bulk-import/keys` — redirects to the canonical JWKS endpoint.
///
/// The authoritative key set is now served at
/// `/.well-known/bulk-submit-jwks.json` by the REST layer, which derives the
/// JWK directly from `HFS_BULK_SUBMIT_PRIVATE_KEY` (#529). This redirect keeps
/// any existing bookmarks working.
pub async fn keys() -> Response {
match std::env::var("HFS_BULK_SUBMIT_PUBLIC_JWK")
.ok()
.and_then(|raw| serde_json::from_str::<Value>(&raw).ok())
{
Some(jwk) => axum::Json(json!({ "keys": [jwk] })).into_response(),
None => StatusCode::NOT_FOUND.into_response(),
}
axum::response::Redirect::permanent("/.well-known/bulk-submit-jwks.json").into_response()
}

/// `GET /ui/bulk-import/empty-manifest.json` — an empty Bulk Export Manifest.
Expand Down
6 changes: 6 additions & 0 deletions crates/ui/templates/pages/bulk-import.html
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,12 @@ <h1 class="page-title">{{ i18n.t("bulk-import-title") }}</h1>
<input class="field__input" type="url" name="token_url" autocomplete="off">
<span class="field__hint">{{ i18n.t("bulk-import-field-token-url-hint") }}</span>
</label>
<p class="field__hint">
{{ i18n.t("bulk-import-jwks-hint") }}
<a href="/.well-known/bulk-submit-jwks.json" target="_blank" rel="noopener">
/.well-known/bulk-submit-jwks.json
</a>
</p>
<button type="submit" class="button"
formaction="/ui/bulk-import/test-auth"
hx-post="/ui/bulk-import/test-auth"
Expand Down
1 change: 1 addition & 0 deletions locales/de/main.ftl
Original file line number Diff line number Diff line change
Expand Up @@ -532,6 +532,7 @@ bulk-import-field-client-id = Client-ID
bulk-import-field-client-id-hint = Registrieren Sie diesen Datenanbieter beim Empfänger und erhalten Sie eine Client-ID.
bulk-import-field-token-url = Token-URL
bulk-import-field-token-url-hint = Token-Endpunkt-URL des Autorisierungsservers.
bulk-import-jwks-hint = Registrieren Sie den öffentlichen Schlüssel dieses Servers beim Empfänger über die JWKS-URL:
bulk-import-test-auth = Authentifizierung testen
bulk-import-test-auth-ok = Authentifizierung erfolgreich.
bulk-import-create-submit = Submission anlegen
Expand Down
1 change: 1 addition & 0 deletions locales/en/main.ftl
Original file line number Diff line number Diff line change
Expand Up @@ -535,6 +535,7 @@ bulk-import-field-client-id = Client ID
bulk-import-field-client-id-hint = Register this data provider with the Data Recipient and get back a client ID.
bulk-import-field-token-url = Token URL
bulk-import-field-token-url-hint = Authorization server's token endpoint URL.
bulk-import-jwks-hint = Register this server's public key with the recipient using the JWKS URL:
bulk-import-test-auth = Test authentication
bulk-import-test-auth-ok = Authentication succeeded.
bulk-import-create-submit = Create submission
Expand Down
Loading