Skip to content
Draft
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
1 change: 1 addition & 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 Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
1 change: 1 addition & 0 deletions server/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
12 changes: 12 additions & 0 deletions server/auth_server.dev.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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"
2 changes: 1 addition & 1 deletion server/documentation/api_reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
6 changes: 3 additions & 3 deletions server/documentation/authentication_flows.md
Original file line number Diff line number Diff line change
Expand Up @@ -143,7 +143,7 @@ sequenceDiagram

C->>API: GET /api/resource<br/>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)
Expand All @@ -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

Expand Down Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion server/documentation/authorization_and_administration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`) |

Expand Down
18 changes: 14 additions & 4 deletions server/documentation/openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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:
Expand Down
126 changes: 29 additions & 97 deletions server/src/server/auth_server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::{
Expand All @@ -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<ServerParams>,
Expand Down Expand Up @@ -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) = &params.dev_seed {
use crate::server::dev_seed::seed_dev_realm_admin;
seed_dev_realm_admin(database.as_ref(), seed).await?;
}

Expand Down Expand Up @@ -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();

Expand All @@ -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
Expand Down Expand Up @@ -268,6 +197,7 @@ fn build_app(
jwks_manager: Arc<JwksManager>,
default_username: Option<String>,
jwt_token_config: Arc<JwtTokenConfig>,
jwks_data: Arc<JwksData>,
) -> App<
impl ServiceFactory<
ServiceRequest,
Expand All @@ -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 = {
Expand Down
Loading