diff --git a/Cargo.lock b/Cargo.lock
index 5e6c89c..002c4f6 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -337,6 +337,7 @@ dependencies = [
"hex",
"jsonwebtoken",
"openssl",
+ "pem",
"redis",
"reqwest 0.13.2",
"rustls",
diff --git a/Cargo.toml b/Cargo.toml
index e204aa2..e034881 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -30,6 +30,7 @@ futures = "0.3.31"
hex = { version = "0.4.3", features = ["serde"] }
jsonwebtoken = "10.3.0"
openssl = { version = "0.10" }
+pem = "3.0.6"
redis = { version = "0.32.6", features = ["aio", "tokio-comp", "bb8"] }
reqwest = { version = "0.13.2" }
rustls = "0.23.35"
diff --git a/server/Cargo.toml b/server/Cargo.toml
index 1e49f38..c0d3a31 100644
--- a/server/Cargo.toml
+++ b/server/Cargo.toml
@@ -36,6 +36,7 @@ futures = { workspace = true }
hex = { workspace = true }
jsonwebtoken = { workspace = true, features = ["aws_lc_rs", "use_pem"] }
openssl = { workspace = true, features = ["vendored"], optional = true }
+pem = { workspace = true }
redis = { workspace = true }
reqwest = { workspace = true, features = [
"json",
diff --git a/server/auth_server.dev.toml b/server/auth_server.dev.toml
index 1fcf54a..e02aa61 100644
--- a/server/auth_server.dev.toml
+++ b/server/auth_server.dev.toml
@@ -36,6 +36,11 @@ admin_ui_path = "admin-ui/dist"
# ---------------------------------------------------------------------------
roles = ["SuperAdmin", "DomainAdmin", "CryptoOfficer", "Auditor", "User"]
+# Pre-configure a fixed TOTP secret for the basic admin ("admin" / realm "_").
+# Use any Base32 TOTP secret you generate once, e.g.:
+# python3 -c "import base64, os; print(base64.b32encode(os.urandom(20)).decode())"
+# admin_totp_secret = "GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ"
+
# ---------------------------------------------------------------------------
# TLS — uses the test EC P-256 certificates bundled with the source tree.
# These are self-signed and must NOT be used in production.
@@ -68,3 +73,10 @@ auto_init_schema = true
realm_id = "dev-realm"
admin_username = "realm-admin"
admin_password = "change_me"
+
+# TOTP-enabled user for testing two-factor authentication.
+# Use the secret below with any TOTP app (Google Authenticator, Authy, etc.).
+# Login requires both the password and the current TOTP code.
+totp_username = "totp-user"
+totp_password = "change_me"
+totp_secret = "GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ"
diff --git a/server/documentation/api_reference.md b/server/documentation/api_reference.md
index 6dd9f04..509c643 100644
--- a/server/documentation/api_reference.md
+++ b/server/documentation/api_reference.md
@@ -40,7 +40,7 @@ Content-Type: text/plain
---
-### `GET /public/jwks`
+### `GET /.well-known/jwks.json`
Returns the JSON Web Key Set containing the server's public key(s) for JWT signature verification.
diff --git a/server/documentation/authentication_flows.md b/server/documentation/authentication_flows.md
index aa09f05..1ef0a82 100644
--- a/server/documentation/authentication_flows.md
+++ b/server/documentation/authentication_flows.md
@@ -143,7 +143,7 @@ sequenceDiagram
C->>API: GET /api/resource
Authorization: Bearer eyJhbG…
note over API: JwtAuth middleware
- API->>EA: GET /public/jwks (key discovery)
+ API->>EA: GET /.well-known/jwks.json (key discovery)
EA-->>API: JWKS (public keys)
note over API: Validates JWT signature + exp + aud
API->>SS: upsert_session(jwt_claims)
@@ -153,7 +153,7 @@ sequenceDiagram
### Key discovery
-The server exposes a JWKS endpoint at `GET /public/jwks` which returns the RSA/EC public keys used to verify tokens. The JWT middleware caches this document and refreshes it on a configurable interval.
+The server exposes a JWKS endpoint at `GET /.well-known/jwks.json` which returns the RSA/EC public keys used to verify tokens. The JWT middleware caches this document and refreshes it on a configurable interval.
### JWT requirements
@@ -243,7 +243,7 @@ DELETE /sessions/realms/{realm_id}
| `POST` | `/login?realm={realm}` | Authenticate and issue a session cookie | No (credentials in body) |
| `GET` | `/whoami?realm={realm}` | Return the current session's claims | Session cookie |
| `GET` | `/public/version` | Server version string | No |
-| `GET` | `/public/jwks` | JSON Web Key Set for JWT verification | No |
+| `GET` | `/.well-known/jwks.json` | JSON Web Key Set for JWT verification | No |
### Session management endpoints
diff --git a/server/documentation/authorization_and_administration.md b/server/documentation/authorization_and_administration.md
index 0c34e6f..6abdd2a 100644
--- a/server/documentation/authorization_and_administration.md
+++ b/server/documentation/authorization_and_administration.md
@@ -142,7 +142,7 @@ All `/realms/{realm}/userpass` endpoints require `can_administer_realm(realm)`.
| Method | Endpoint | Anyone |
|--------|---------|:------:|
| `GET` | `/public/version` | ✅ |
-| `GET` | `/public/jwks` | ✅ |
+| `GET` | `/.well-known/jwks.json` | ✅ |
| `POST` | `/login` | ✅ |
| `GET` | `/whoami` | ✅ (no `AdminAuth`) |
diff --git a/server/documentation/openapi.yaml b/server/documentation/openapi.yaml
index 24c20b6..4a2d611 100644
--- a/server/documentation/openapi.yaml
+++ b/server/documentation/openapi.yaml
@@ -554,14 +554,14 @@ paths:
example:
version: 1.0.0
- /public/jwks:
+ /.well-known/jwks.json:
get:
tags: [Public]
- summary: JSON Web Key Set (test builds only)
+ summary: JSON Web Key Set
operationId: getJwks
responses:
'200':
- description: JWKS document.
+ description: JWKS document containing the server's EC P-256 signing public key.
content:
application/json:
schema:
@@ -571,13 +571,23 @@ paths:
type: array
items:
type: object
+ properties:
+ kty: { type: string }
+ crv: { type: string }
+ use: { type: string }
+ alg: { type: string }
+ kid: { type: string }
+ x: { type: string }
+ y: { type: string }
example:
keys:
- kty: EC
crv: P-256
+ use: sig
+ alg: ES256
+ kid: a3f1c2d4e5b6...
x: f83OJ3D2xF1Bg8vub9tLe1gHMzV76e8Tus9uPHvRVEU
y: x_FEzRu9m36HLN_tue659LNpXW6pCyStikYjKIWI5a0
- use: sig
/login:
post:
diff --git a/server/src/server/auth_server.rs b/server/src/server/auth_server.rs
index 623430c..a547a5e 100644
--- a/server/src/server/auth_server.rs
+++ b/server/src/server/auth_server.rs
@@ -7,14 +7,14 @@ use crate::{
add_admin_to_realm, create_admin, create_realm, create_userpass, delete_admin,
delete_expired_sessions, delete_realm, delete_sessions, delete_sessions_for_realm,
delete_userpass, get_admin, get_realm, get_session, get_session_by_id,
- get_sessions_for_clients, get_userpass, list_admins, list_all_userpass, list_realms,
- list_userpass_by_realm, login, remove_admin_from_realm, roles_endpoint, totp_disable,
- totp_generate, totp_verify, update_admin, update_realm, update_userpass,
- upsert_session, version_endpoint, whoami,
+ get_sessions_for_clients, get_userpass, jwks_well_known, list_admins,
+ list_all_userpass, list_realms, list_userpass_by_realm, login, remove_admin_from_realm,
+ roles_endpoint, totp_disable, totp_generate, totp_verify, update_admin, update_realm,
+ update_userpass, upsert_session, version_endpoint, whoami,
},
- parameters::{DatabaseBackend, DatabaseParams, DevSeedParams, ServerParams},
+ parameters::{DatabaseBackend, DatabaseParams, ServerParams},
},
- session::{self, JwtTokenConfig},
+ session::{self, JwksData, JwtTokenConfig},
};
use actix_cors::Cors;
use actix_web::{
@@ -36,96 +36,6 @@ use crate::tls::openssl_config::{create_openssl_acceptor, extract_openssl_peer_c
#[cfg(feature = "rustls")]
use crate::tls::rustls_config::{extract_rustls_peer_certificate, rustls_server_config};
-/// Seeds a realm and a realm-scoped admin account for development use.
-/// All operations are idempotent — nothing is overwritten if it already exists.
-async fn seed_dev_realm_admin(
- db: &dyn crate::database::Database,
- seed: &DevSeedParams,
-) -> AuthResult<()> {
- use crate::database::hash_password_with_argon2;
- use crate::models::{ADMIN_REALM, Admin, Realm, UserPass};
- use crate::{RealmAuthParams, UsernamePasswordParams};
-
- // 1. Create the realm if it does not exist.
- if db.get_realm(&seed.realm_id).await?.is_none() {
- let realm = Realm {
- id: seed.realm_id.clone(),
- auth_params: RealmAuthParams {
- username_password_params: Some(UsernamePasswordParams {
- allow_expired_passwords: false,
- }),
- ..Default::default()
- },
- session_max_age_seconds: 3600,
- session_max_stale_age_seconds: 3600,
- };
- db.create_realm(&realm).await.map_err(|e| {
- crate::AuthError::Init(format!(
- "dev_seed: failed to create realm '{}': {e}",
- seed.realm_id
- ))
- })?;
- info!("dev_seed: created realm '{}'", seed.realm_id);
- }
-
- // 2. Create the credential in the admin realm if it does not exist.
- if db
- .get_userpass(ADMIN_REALM, &seed.admin_username)
- .await?
- .is_none()
- {
- let hashed = hash_password_with_argon2(&seed.admin_username, &seed.admin_password)
- .map_err(|e| {
- crate::AuthError::Init(format!(
- "dev_seed: failed to hash password for '{}': {e}",
- seed.admin_username
- ))
- })?;
- let userpass = UserPass {
- realm: ADMIN_REALM.to_string(),
- username: seed.admin_username.clone(),
- password: hashed,
- change_password: true,
- roles: Vec::new(),
- };
- db.create_userpass(&userpass).await.map_err(|e| {
- crate::AuthError::Init(format!(
- "dev_seed: failed to create credential for '{}': {e}",
- seed.admin_username
- ))
- })?;
- info!("dev_seed: created credential for '{}'", seed.admin_username);
- }
-
- // 3. Create the admin record if it does not exist.
- if db.get_admin(&seed.admin_username).await?.is_none() {
- let admin = Admin {
- id: seed.admin_username.clone(),
- realms: vec![seed.realm_id.clone()],
- userpass: Some(seed.admin_username.clone()),
- jwt: None,
- fido2: None,
- digital_credentials: None,
- client_certificate: None,
- totp_enabled: None,
- totp_secret: None,
- totp_auth_url: None,
- };
- db.create_admin(&admin).await.map_err(|e| {
- crate::AuthError::Init(format!(
- "dev_seed: failed to create admin '{}': {e}",
- seed.admin_username
- ))
- })?;
- info!(
- "dev_seed: created realm-admin '{}' for realm '{}'",
- seed.admin_username, seed.realm_id
- );
- }
-
- Ok(())
-}
-
/// Inner function to start the test server asynchronously.
pub async fn start_auth_server(
server_params: Arc,
@@ -181,6 +91,7 @@ async fn prepare_auth_server(
// If a dev_seed config is present, ensure the seeded realm and realm-admin exist.
if let Some(seed) = ¶ms.dev_seed {
+ use crate::server::dev_seed::seed_dev_realm_admin;
seed_dev_realm_admin(database.as_ref(), seed).await?;
}
@@ -215,6 +126,23 @@ async fn prepare_auth_server(
decoding_key: params.get_jwt_decoding_key()?,
});
+ // Build the JWKS document once from the JWT signing public key.
+ // Reads the same PEM path that get_jwt_decoding_key() uses.
+ let jwks_pem_path = params
+ .session_jwt_params
+ .as_ref()
+ .map(|p| p.jwt_ec_public_key.clone())
+ .unwrap_or_else(|| params.tls_params.server_certificate.clone());
+ let jwks_pem = std::fs::read_to_string(&jwks_pem_path).map_err(|e| {
+ crate::AuthError::Init(format!(
+ "Failed to read JWT public key PEM for JWKS ({jwks_pem_path}): {e}"
+ ))
+ })?;
+ let jwks_data = Arc::new(
+ crate::session::build_jwks_from_pem(&jwks_pem)
+ .map_err(|e| crate::AuthError::Init(format!("Failed to build JWKS: {e}")))?,
+ );
+
// Clone test server params for HttpServer closure
let server_params = params.clone();
@@ -229,6 +157,7 @@ async fn prepare_auth_server(
jwks_manager.clone(),
default_username.clone(),
jwt_token_config.clone(),
+ jwks_data.clone(),
)
});
let http_server = http_server
@@ -268,6 +197,7 @@ fn build_app(
jwks_manager: Arc,
default_username: Option,
jwt_token_config: Arc,
+ jwks_data: Arc,
) -> App<
impl ServiceFactory<
ServiceRequest,
@@ -285,8 +215,10 @@ fn build_app(
.app_data(Data::new(database.clone()))
.app_data(Data::new(session_store.clone()))
.app_data(Data::new(jwt_token_config.clone()))
+ .app_data(Data::new(jwks_data.clone()))
.app_data(PayloadConfig::new(1_000_000))
- .app_data(JsonConfig::default().limit(1_000_000));
+ .app_data(JsonConfig::default().limit(1_000_000))
+ .route("/.well-known/jwks.json", web::get().to(jwks_well_known));
#[cfg(test)]
let app = {
diff --git a/server/src/server/dev_seed.rs b/server/src/server/dev_seed.rs
new file mode 100644
index 0000000..7363131
--- /dev/null
+++ b/server/src/server/dev_seed.rs
@@ -0,0 +1,189 @@
+use crate::{
+ AuthResult,
+ database::{Database, hash_password_with_argon2},
+ models::{ADMIN_REALM, Admin, Realm, UserPass},
+ server::parameters::DevSeedParams,
+ {RealmAuthParams, UsernamePasswordParams},
+};
+use cosmian_logger::info;
+
+/// Seeds a realm and a realm-scoped admin account for development use.
+/// All operations are idempotent — nothing is overwritten if it already exists.
+pub(super) async fn seed_dev_realm_admin(
+ db: &dyn Database,
+ seed: &DevSeedParams,
+) -> AuthResult<()> {
+ // 1. Create the realm if it does not exist.
+ if db.get_realm(&seed.realm_id).await?.is_none() {
+ let realm = Realm {
+ id: seed.realm_id.clone(),
+ auth_params: RealmAuthParams {
+ username_password_params: Some(UsernamePasswordParams {
+ allow_expired_passwords: false,
+ }),
+ ..Default::default()
+ },
+ session_max_age_seconds: 3600,
+ session_max_stale_age_seconds: 3600,
+ };
+ db.create_realm(&realm).await.map_err(|e| {
+ crate::AuthError::Init(format!(
+ "dev_seed: failed to create realm '{}': {e}",
+ seed.realm_id
+ ))
+ })?;
+ info!("dev_seed: created realm '{}'", seed.realm_id);
+ }
+
+ // 2. Create the credential in the admin realm if it does not exist.
+ if db
+ .get_userpass(ADMIN_REALM, &seed.admin_username)
+ .await?
+ .is_none()
+ {
+ let hashed = hash_password_with_argon2(&seed.admin_username, &seed.admin_password)
+ .map_err(|e| {
+ crate::AuthError::Init(format!(
+ "dev_seed: failed to hash password for '{}': {e}",
+ seed.admin_username
+ ))
+ })?;
+ let userpass = UserPass {
+ realm: ADMIN_REALM.to_string(),
+ username: seed.admin_username.clone(),
+ password: hashed,
+ change_password: true,
+ roles: Vec::new(),
+ };
+ db.create_userpass(&userpass).await.map_err(|e| {
+ crate::AuthError::Init(format!(
+ "dev_seed: failed to create credential for '{}': {e}",
+ seed.admin_username
+ ))
+ })?;
+ info!("dev_seed: created credential for '{}'", seed.admin_username);
+ }
+
+ // 3. Create the admin record if it does not exist.
+ if db.get_admin(&seed.admin_username).await?.is_none() {
+ let admin = Admin {
+ id: seed.admin_username.clone(),
+ realms: vec![seed.realm_id.clone()],
+ userpass: Some(seed.admin_username.clone()),
+ jwt: None,
+ fido2: None,
+ digital_credentials: None,
+ client_certificate: None,
+ totp_enabled: None,
+ totp_secret: None,
+ totp_auth_url: None,
+ };
+ db.create_admin(&admin).await.map_err(|e| {
+ crate::AuthError::Init(format!(
+ "dev_seed: failed to create admin '{}': {e}",
+ seed.admin_username
+ ))
+ })?;
+ info!(
+ "dev_seed: created realm-admin '{}' for realm '{}'",
+ seed.admin_username, seed.realm_id
+ );
+ }
+
+ // 4. Optionally create a TOTP-enabled user in the seeded realm.
+ if let (Some(totp_username), Some(totp_password)) = (&seed.totp_username, &seed.totp_password) {
+ if db
+ .get_userpass(&seed.realm_id, totp_username)
+ .await?
+ .is_none()
+ {
+ let hashed = hash_password_with_argon2(totp_username, totp_password).map_err(|e| {
+ crate::AuthError::Init(format!(
+ "dev_seed: failed to hash password for TOTP user '{}': {e}",
+ totp_username
+ ))
+ })?;
+ let userpass = UserPass {
+ realm: seed.realm_id.clone(),
+ username: totp_username.clone(),
+ password: hashed,
+ change_password: false,
+ roles: Vec::new(),
+ };
+ db.create_userpass(&userpass).await.map_err(|e| {
+ crate::AuthError::Init(format!(
+ "dev_seed: failed to create TOTP user '{}': {e}",
+ totp_username
+ ))
+ })?;
+ info!(
+ "dev_seed: created TOTP user '{}' in realm '{}'",
+ totp_username, seed.realm_id
+ );
+ }
+
+ // Create the admin record for the TOTP user if it does not exist (required for TOTP fields in DB).
+ if db.get_admin(totp_username).await?.is_none() {
+ let admin = Admin {
+ id: totp_username.clone(),
+ realms: Vec::new(), // not an admin of any realms
+ userpass: Some(totp_username.clone()),
+ jwt: None,
+ fido2: None,
+ digital_credentials: None,
+ client_certificate: None,
+ totp_enabled: None,
+ totp_secret: None,
+ totp_auth_url: None,
+ };
+ db.create_admin(&admin).await.map_err(|e| {
+ crate::AuthError::Init(format!(
+ "dev_seed: failed to create admin record for TOTP user '{}': {e}",
+ totp_username
+ ))
+ })?;
+ info!(
+ "dev_seed: created admin record for TOTP user '{}'",
+ totp_username
+ );
+ }
+
+ // Enable TOTP for the user (idempotent: skip if already enabled).
+ let already_enabled = db
+ .is_totp_enabled(&seed.realm_id, totp_username)
+ .await?
+ .unwrap_or(false);
+ if !already_enabled {
+ let secret = if let Some(s) = &seed.totp_secret {
+ s.clone()
+ } else {
+ let generated = crate::totp::Totps::new()
+ .map_err(|e| {
+ crate::AuthError::Init(format!(
+ "dev_seed: failed to generate TOTP secret: {e}"
+ ))
+ })?
+ .generate_secret();
+ info!(
+ "dev_seed: generated TOTP secret for '{}': {}",
+ totp_username, generated
+ );
+ generated
+ };
+ db.enable_totp(&seed.realm_id, totp_username, &secret, &seed.realm_id)
+ .await
+ .map_err(|e| {
+ crate::AuthError::Init(format!(
+ "dev_seed: failed to enable TOTP for '{}': {e}",
+ totp_username
+ ))
+ })?;
+ info!(
+ "dev_seed: TOTP enabled for user '{}' in realm '{}'",
+ totp_username, seed.realm_id
+ );
+ }
+ }
+
+ Ok(())
+}
diff --git a/server/src/server/endpoints/client_endpoints.rs b/server/src/server/endpoints/client_endpoints.rs
index d2643a9..e6940db 100644
--- a/server/src/server/endpoints/client_endpoints.rs
+++ b/server/src/server/endpoints/client_endpoints.rs
@@ -1,6 +1,6 @@
use crate::models::ClientClaims;
use crate::models::LoginRequest;
-use crate::session::{self, issue_token, session_id_from_cookie_value};
+use crate::session::{self, JwksData, issue_token, session_id_from_cookie_value};
use crate::{AuthError, AuthenticatedClientScheme, server::Version};
use crate::{AuthenticationNextStep, AuthenticationResult, Realm, build_cookie};
use actix_web::HttpMessage;
@@ -154,6 +154,14 @@ pub async fn roles_endpoint(
Ok(HttpResponse::Ok().json(&server_params.roles))
}
+/// Returns the JWKS (JSON Web Key Set) document containing the server's EC signing
+/// public key. Served at `/.well-known/jwks.json` per OIDC / RFC 8414.
+pub async fn jwks_well_known(jwks: Data>) -> Result {
+ Ok(HttpResponse::Ok()
+ .insert_header(("Cache-Control", "public, max-age=3600"))
+ .json(&jwks.0))
+}
+
/// Serve the raw OpenAPI 3.1 schema (YAML), embedded at compile time.
#[cfg(feature = "swagger-ui")]
pub async fn openapi_yaml_endpoint(_req: HttpRequest) -> Result {
diff --git a/server/src/server/endpoints/mod.rs b/server/src/server/endpoints/mod.rs
index a701382..c72eaa7 100644
--- a/server/src/server/endpoints/mod.rs
+++ b/server/src/server/endpoints/mod.rs
@@ -16,7 +16,7 @@ pub use realms_endpoints::{
};
mod client_endpoints;
-pub use client_endpoints::{login, roles_endpoint, version_endpoint, whoami};
+pub use client_endpoints::{jwks_well_known, login, roles_endpoint, version_endpoint, whoami};
#[cfg(feature = "swagger-ui")]
pub use client_endpoints::{openapi_yaml_endpoint, swagger_ui_endpoint};
diff --git a/server/src/server/mod.rs b/server/src/server/mod.rs
index f6a6925..3d2649b 100644
--- a/server/src/server/mod.rs
+++ b/server/src/server/mod.rs
@@ -1,4 +1,5 @@
mod auth_server;
+mod dev_seed;
pub use auth_server::start_auth_server;
pub mod endpoints;
diff --git a/server/src/server/parameters/server_params.rs b/server/src/server/parameters/server_params.rs
index 70dd483..1f3911c 100644
--- a/server/src/server/parameters/server_params.rs
+++ b/server/src/server/parameters/server_params.rs
@@ -60,6 +60,13 @@ pub struct DevSeedParams {
pub admin_username: String,
/// Plain-text password for the realm-admin account.
pub admin_password: String,
+ /// Username for a TOTP-enabled regular user in the seeded realm (optional).
+ pub totp_username: Option,
+ /// Plain-text password for the TOTP-enabled user (optional).
+ pub totp_password: Option,
+ /// Fixed Base32 TOTP secret for the TOTP user (optional).
+ /// If omitted, a random secret is generated and logged at startup.
+ pub totp_secret: Option,
}
impl ServerParams {
diff --git a/server/src/session/jwt.rs b/server/src/session/jwt.rs
index 28da59b..cc04042 100644
--- a/server/src/session/jwt.rs
+++ b/server/src/session/jwt.rs
@@ -4,6 +4,152 @@ use jsonwebtoken::{Algorithm, DecodingKey, EncodingKey, Header, Validation, deco
use crate::{AuthError, AuthScheme, models::ClientClaims};
+/// Pre-built JWKS document served at `/.well-known/jwks.json`.
+pub struct JwksData(pub serde_json::Value);
+
+/// Build a JWKS document from a PEM string (either an X.509 certificate or a
+/// SubjectPublicKeyInfo / `BEGIN PUBLIC KEY` file). The resulting set contains a
+/// single EC P-256 `sig` key with a deterministic `kid` derived from the
+/// SHA-256 digest of the raw x‖y bytes.
+///
+/// Supported PEM types: `BEGIN CERTIFICATE`, `BEGIN PUBLIC KEY`.
+#[cfg(feature = "openssl")]
+pub fn build_jwks_from_pem(pem: &str) -> crate::AuthResult {
+ use base64::Engine as _;
+ use base64::engine::general_purpose::URL_SAFE_NO_PAD;
+ use sha2::{Digest, Sha256};
+
+ let xy = ec_xy_from_pem_openssl(pem)?;
+
+ let kid = hex::encode(Sha256::digest(&xy));
+ let jwk = serde_json::json!({
+ "kty": "EC",
+ "crv": "P-256",
+ "use": "sig",
+ "alg": "ES256",
+ "kid": kid,
+ "x": URL_SAFE_NO_PAD.encode(&xy[0..32]),
+ "y": URL_SAFE_NO_PAD.encode(&xy[32..64]),
+ });
+ Ok(JwksData(serde_json::json!({ "keys": [jwk] })))
+}
+
+#[cfg(feature = "openssl")]
+fn ec_xy_from_pem_openssl(pem: &str) -> crate::AuthResult> {
+ if pem.contains("BEGIN CERTIFICATE") {
+ let der = pem_to_der_bytes(pem, "CERTIFICATE")?;
+ let cert = openssl::x509::X509::from_der(&der)
+ .map_err(|e| AuthError::Config(format!("Failed to parse X.509 certificate: {e}")))?;
+ let pkey = cert
+ .public_key()
+ .map_err(|e| AuthError::Config(format!("Failed to extract public key: {e}")))?;
+ ec_xy_from_pkey(&pkey)
+ } else {
+ let der = pem_to_der_bytes(pem, "PUBLIC KEY")?;
+ let pkey = openssl::pkey::PKey::public_key_from_der(&der)
+ .map_err(|e| AuthError::Config(format!("Failed to parse public key DER: {e}")))?;
+ ec_xy_from_pkey(&pkey)
+ }
+}
+
+#[cfg(feature = "openssl")]
+fn ec_xy_from_pkey(
+ pkey: &openssl::pkey::PKey,
+) -> crate::AuthResult> {
+ let ec = pkey
+ .ec_key()
+ .map_err(|e| AuthError::Config(format!("JWT key is not an EC key: {e}")))?;
+ let group = ec.group();
+ let point = ec.public_key();
+ let mut ctx = openssl::bn::BigNumContext::new()
+ .map_err(|e| AuthError::Config(format!("Failed to create BigNum context: {e}")))?;
+ let bytes = point
+ .to_bytes(
+ group,
+ openssl::ec::PointConversionForm::UNCOMPRESSED,
+ &mut ctx,
+ )
+ .map_err(|e| AuthError::Config(format!("Failed to export EC point: {e}")))?;
+ if bytes.len() != 65 || bytes[0] != 0x04 {
+ return Err(AuthError::Config(format!(
+ "Expected 65-byte uncompressed P-256 point, got {} bytes",
+ bytes.len()
+ )));
+ }
+ Ok(bytes[1..].to_vec()) // 64 bytes: x‖y
+}
+
+/// Build a JWKS document from a PEM string.
+///
+/// This is the `rustls`-only (no OpenSSL) implementation. It parses the raw DER
+/// bytes to locate the uncompressed EC public-key point directly.
+#[cfg(all(feature = "rustls", not(feature = "openssl")))]
+pub fn build_jwks_from_pem(pem: &str) -> crate::AuthResult {
+ use base64::Engine as _;
+ use base64::engine::general_purpose::URL_SAFE_NO_PAD;
+ use sha2::{Digest, Sha256};
+
+ let xy = ec_xy_from_pem_der(pem)?;
+
+ let kid = hex::encode(Sha256::digest(&xy));
+ let jwk = serde_json::json!({
+ "kty": "EC",
+ "crv": "P-256",
+ "use": "sig",
+ "alg": "ES256",
+ "kid": kid,
+ "x": URL_SAFE_NO_PAD.encode(&xy[0..32]),
+ "y": URL_SAFE_NO_PAD.encode(&xy[32..64]),
+ });
+ Ok(JwksData(serde_json::json!({ "keys": [jwk] })))
+}
+
+/// Locate the 64-byte x‖y from a P-256 EC key embedded in raw DER bytes.
+///
+/// The uncompressed-point marker `0x04` is always the last content byte of the
+/// BitString that encodes the SubjectPublicKeyInfo's public key. In a P-256
+/// SubjectPublicKeyInfo the DER is exactly 91 bytes and `0x04` is at byte 26.
+/// In an X.509 certificate the same BitString appears near the end of the
+/// TBSCertificate, so scanning backwards for `0x00 0x04` (no unused bits +
+/// uncompressed marker) followed by exactly 64 bytes is reliable.
+#[cfg(all(feature = "rustls", not(feature = "openssl")))]
+fn ec_xy_from_pem_der(pem: &str) -> crate::AuthResult<[u8; 64]> {
+ let label = if pem.contains("BEGIN CERTIFICATE") {
+ "CERTIFICATE"
+ } else {
+ "PUBLIC KEY"
+ };
+ let der = pem_to_der_bytes(pem, label)?;
+
+ // Search for the last occurrence of [0x00, 0x04] (BitString no-unused-bits +
+ // uncompressed EC point marker) where 64 bytes follow.
+ let pos = der
+ .windows(2)
+ .enumerate()
+ .rev()
+ .find(|(i, w)| w[0] == 0x00 && w[1] == 0x04 && i + 2 + 64 <= der.len())
+ .map(|(i, _)| i + 2) // first byte of x
+ .ok_or_else(|| {
+ AuthError::Config("Cannot locate P-256 uncompressed point in DER".to_string())
+ })?;
+
+ let mut xy = [0u8; 64];
+ xy.copy_from_slice(&der[pos..pos + 64]);
+ Ok(xy)
+}
+
+fn pem_to_der_bytes(pem_str: &str, label: &str) -> crate::AuthResult> {
+ let parsed = pem::parse(pem_str)
+ .map_err(|e| AuthError::Config(format!("Failed to parse PEM ({label}): {e}")))?;
+ if parsed.tag() != label {
+ return Err(AuthError::Config(format!(
+ "Expected PEM label {label}, got {}",
+ parsed.tag()
+ )));
+ }
+ Ok(parsed.into_contents())
+}
+
pub struct JwtTokenConfig {
pub algorithm: Algorithm,
pub encoding_key: EncodingKey,
diff --git a/server/src/session/mod.rs b/server/src/session/mod.rs
index 868ab55..620e77f 100644
--- a/server/src/session/mod.rs
+++ b/server/src/session/mod.rs
@@ -5,7 +5,7 @@ mod factory;
pub use factory::create_session_store_with_collector;
mod jwt;
-pub use jwt::{JwtTokenConfig, issue_token, validate_token};
+pub use jwt::{JwksData, JwtTokenConfig, build_jwks_from_pem, issue_token, validate_token};
mod session_store;
pub use session_store::SessionStore;