diff --git a/README.md b/README.md index bf13ba4..c4341e1 100644 --- a/README.md +++ b/README.md @@ -227,6 +227,25 @@ You can run the server directly from your terminal - no code required. oauth2-test-server ``` +Or start with a YAML/TOML config file: + +```bash +oauth2-test-server --config ./config.sample.yaml +``` + +Or via environment variables: + +```bash +OAUTH_PORT=9000 OAUTH_DEFAULT_USER_ID=alice oauth2-test-server +``` + +Or generate starter config files in the current directory: + +```bash +oauth2-test-server -generate-config-sample-yaml +oauth2-test-server -generate-config-env-sample +``` + You’ll see: ``` OAuth Test Server running on http://127.0.0.1:8090/ @@ -275,7 +294,38 @@ let config = IssuerConfig { let server = OAuthTestServer::start_with_config(config).await; ``` -Or, when using the library in your own tests, load from environment variables (`OAUTH_*`) or a YAML/TOML file: +Use either the standalone binary flag or the library API for custom configuration. + +Binary: + +```bash +# Generate sample files in the current directory (fails if the file already exists) +oauth2-test-server -generate-config-sample-yaml +oauth2-test-server -generate-config-env-sample + +# Load from file +oauth2-test-server --config ./config.sample.yaml + +# Env overrides +OAUTH_PORT=9000 OAUTH_REQUIRE_STATE=false oauth2-test-server + +# Scalar CLI overrides (take highest precedence) +oauth2-test-server --config ./config.sample.yaml --port 7777 --default-user-id alice +``` + +Supported scalar CLI overrides: +- `--scheme` +- `--host` +- `--port` +- `--default-user-id` +- `--require-state` +- `--generate-client-secret-for-dcr` +- `--access-token-expires-in` +- `--refresh-token-expires-in` +- `--authorization-code-expires-in` +- `--cleanup-interval-secs` + +Library: ```rust // From environment variables (requires "config" feature) @@ -285,9 +335,8 @@ let config = IssuerConfig::from_env()?; let config = IssuerConfig::from_file("path/to/config.yaml")?; ``` -> **Note:** The standalone binary (`oauth2-test-server`) does not currently accept CLI flags or config files. Use the library API for custom configuration. - A complete sample config file with all options and their defaults can be found at [`config.sample.yaml`](./config.sample.yaml). +An environment-variable sample file can be generated as `./.config.sample.env` via `-generate-config-env-sample`. ### All Configuration Options @@ -307,11 +356,14 @@ A complete sample config file with all options and their defaults can be found a ### Loading Order -1. Programmatic `IssuerConfig` (highest priority) -2. Environment variables (`OAUTH_*`) +For the standalone binary: +1. Scalar CLI overrides (highest priority) +2. Environment variables (`OAUTH_*`) for scalar fields 3. YAML/TOML config file (detected by extension: `.yaml`, `.yml`, `.toml`) 4. Built-in defaults (lowest priority) +For library usage, you control precedence explicitly based on whether you call `IssuerConfig::default`, `IssuerConfig::from_env`, `IssuerConfig::from_file`, or your own merge logic. + The `from_env` and `from_file` methods are available when the `config` feature is enabled (included by default). ## How to Use in Tests diff --git a/skills/oauth2-test-server/SKILL.md b/skills/oauth2-test-server/SKILL.md new file mode 100644 index 0000000..e0b9b21 --- /dev/null +++ b/skills/oauth2-test-server/SKILL.md @@ -0,0 +1,164 @@ +# Use oauth2-test-server for Testing and CI + +## When to use this skill + +Use this skill when you need to test OAuth2/OIDC client behavior in Rust tests or CI pipelines without depending on external identity providers. + +Typical cases: +- Integration tests that require authorization_code, refresh_token, device_code, DCR, or OIDC id_token behavior. +- CI smoke tests for auth flows. +- Reproducible auth tests that must run offline and deterministically. + +## Core principles + +1. Prefer in-process testkit in tests +- For Rust tests, start the server using `oauth2_test_server::OAuthTestServer` from the library API. +- This is faster and less flaky than spawning external processes. + +2. Prefer dynamic ports in tests +- Use `IssuerConfig { port: 0, ..Default::default() }` or `OAuthTestServer::start()` so the OS chooses a free port. +- Avoid hardcoded ports in parallel CI. + +3. Keep tests deterministic +- Set explicit token TTLs where timing matters. +- Avoid relying on ambient `OAUTH_*` env vars in tests unless the test explicitly validates env behavior. + +4. Use precedence intentionally +- Standalone binary precedence is: + 1) scalar CLI overrides + 2) scalar `OAUTH_*` env vars + 3) YAML/TOML file via `--config` + 4) built-in defaults + +## Recommended test patterns + +### Pattern A: In-process integration test (preferred) + +```rust +#[tokio::test] +async fn auth_code_flow_works() { + let server = oauth2_test_server::OAuthTestServer::start().await; + + let client = server + .register_client(serde_json::json!({ + "scope": "openid profile email", + "redirect_uris": ["http://localhost:8080/callback"], + "client_name": "ci-client" + })) + .await; + + let token = server + .complete_auth_flow( + &client, + oauth2_test_server::testkit::AuthorizeParams::new() + .redirect_uri("http://localhost:8080/callback") + .scope("openid profile email"), + "ci-user", + ) + .await; + + assert!(token.get("access_token").is_some()); + assert!(token.get("id_token").is_some()); +} +``` + +### Pattern B: Config-specific integration test + +Use explicit config when validating behavior like state enforcement or token expiry. + +```rust +let config = oauth2_test_server::IssuerConfig { + require_state: true, + access_token_expires_in: 300, + port: 0, + ..Default::default() +}; +let server = oauth2_test_server::OAuthTestServer::start_with_config(config).await; +``` + +### Pattern C: Binary smoke test in CI + +Use this only if you need to validate the CLI/binary path itself. + +```bash +oauth2-test-server --config ./config.sample.yaml --port 9010 +``` + +Generate sample config files: + +```bash +oauth2-test-server -generate-config-sample-yaml +oauth2-test-server -generate-config-env-sample +``` + +Example env-driven startup: + +```bash +OAUTH_PORT=9010 OAUTH_DEFAULT_USER_ID=ci-user oauth2-test-server +``` + +## CI command set + +Run this minimal set in CI: + +```bash +cargo test --bin oauth2-test-server +cargo test --test config +cargo test ./... +``` + +If runtime is a concern, run targeted suites first, then full suite on merge. + +## Binary CLI overrides supported (scalar only) + +- `--scheme` +- `--host` +- `--port` +- `--default-user-id` +- `--require-state` +- `--generate-client-secret-for-dcr` +- `--access-token-expires-in` +- `--refresh-token-expires-in` +- `--authorization-code-expires-in` +- `--cleanup-interval-secs` + +## Binary helper options + +- `-generate-config-sample-yaml` / `--generate-config-sample-yaml` + Generates `./.config.sample.yaml` and exits. +- `-generate-config-env-sample` / `--generate-config-env-sample` + Generates `./.config.sample.env` and exits. + +Array/list fields (like scopes, claims, allowed_origins) are not CLI-overridable; set those in config files. + +## Common pitfalls and fixes + +1. Flaky tests due to fixed ports +- Fix: use `port: 0` and read `server.base_url()`. + +2. Hidden env influence in CI +- Fix: clear or scope `OAUTH_*` vars in job steps unless testing env precedence. + +3. Misunderstood precedence +- Fix: remember CLI scalar flags override env and file values. + +4. Invalid bool/number inputs +- Fix: use explicit values: + - bool: `true|false|1|0|yes|no` + - numeric fields: unsigned integers + +## Suggested CI snippet (GitHub Actions) + +```yaml +- name: Run oauth2-test-server focused tests + run: | + cargo test --bin oauth2-test-server + cargo test --test config +``` + +## Definition of done for auth test changes + +- Tests use in-process server unless binary path is under test. +- No hardcoded ports in integration tests. +- New config fields are reflected in `config.sample.yaml` and guarded by tests. +- CI includes focused oauth2-test-server tests. diff --git a/src/config.rs b/src/config.rs index 594d4d5..1ab3afe 100644 --- a/src/config.rs +++ b/src/config.rs @@ -301,6 +301,298 @@ impl IssuerConfig { pub fn from_toml(toml_str: &str) -> Result { toml::from_str(toml_str).map_err(ConfigError::TomlParseError) } + + /// Generate a commented YAML sample that includes every configurable option. + /// + /// The rendered fields are derived from `IssuerConfig` serialization so newly + /// added fields automatically appear in the generated output. + #[cfg(feature = "config")] + pub fn to_sample_yaml() -> Result { + let sample = Self { + port: 8090, + ..Default::default() + }; + + let value = serde_yaml::to_value(&sample).map_err(ConfigError::YamlSerializeError)?; + let mut mapping = value + .as_mapping() + .cloned() + .ok_or(ConfigError::SampleGenerationError( + "issuer config did not serialize to a mapping".to_string(), + ))?; + + let mut out = String::new(); + out.push_str("# OAuth2 Test Server — Sample Configuration\n"); + out.push_str("# Copy this file, edit the values, and load it via\n"); + out.push_str("# IssuerConfig::from_file(\"path/to/config.yaml\")\n"); + out.push_str("#\n"); + out.push_str("# All fields are optional — defaults are shown below.\n"); + + let ordered_sections = sample_sections(); + let inline_comments = sample_inline_comments(); + + for (section_header, keys) in ordered_sections { + out.push_str("\n"); + out.push_str(section_header); + out.push_str("\n"); + + for key in keys { + if let Some(field_value) = take_mapping_field(&mut mapping, key) { + let comment = inline_comments.get(key).copied(); + append_yaml_field(&mut out, key, &field_value, comment)?; + } + } + } + + let mut extras = mapping + .into_iter() + .filter_map(|(k, v)| match k { + serde_yaml::Value::String(name) => Some((name, v)), + _ => None, + }) + .collect::>(); + extras.sort_by(|(left, _), (right, _)| left.cmp(right)); + + if !extras.is_empty() { + out.push_str("\n# --- Additional Options ---\n"); + for (name, field_value) in extras { + append_yaml_field(&mut out, &name, &field_value, None)?; + } + } + + Ok(out) + } + + /// Generate a commented .env sample that includes every configurable option. + /// + /// The rendered fields are derived from `IssuerConfig` serialization so newly + /// added fields automatically appear in the generated output. + #[cfg(feature = "config")] + pub fn to_sample_env() -> Result { + let sample = Self { + port: 8090, + ..Default::default() + }; + + let value = serde_yaml::to_value(&sample).map_err(ConfigError::YamlSerializeError)?; + let mut mapping = value + .as_mapping() + .cloned() + .ok_or(ConfigError::SampleGenerationError( + "issuer config did not serialize to a mapping".to_string(), + ))?; + + let mut out = String::new(); + out.push_str("# OAuth2 Test Server — Sample Environment Configuration\n"); + out.push_str("# Copy this file, edit values, and then export/source it before running oauth2-test-server\n"); + out.push_str("#\n"); + out.push_str("# All fields are optional — defaults are shown below.\n"); + + let ordered_sections = sample_sections(); + let inline_comments = sample_inline_comments(); + + for (section_header, keys) in ordered_sections { + out.push_str("\n"); + out.push_str(section_header); + out.push_str("\n"); + + for key in keys { + if let Some(field_value) = take_mapping_field(&mut mapping, key) { + let env_key = config_key_to_env_var(key); + let rendered = render_env_value(&field_value)?; + + out.push_str(&env_key); + out.push('='); + out.push_str(&rendered); + if let Some(comment) = inline_comments.get(key).copied() { + out.push_str(" # "); + out.push_str(comment); + } + out.push('\n'); + } + } + } + + let mut extras = mapping + .into_iter() + .filter_map(|(k, v)| match k { + serde_yaml::Value::String(name) => Some((name, v)), + _ => None, + }) + .collect::>(); + extras.sort_by(|(left, _), (right, _)| left.cmp(right)); + + if !extras.is_empty() { + out.push_str("\n# --- Additional Options ---\n"); + for (name, field_value) in extras { + let env_key = config_key_to_env_var(&name); + let rendered = render_env_value(&field_value)?; + out.push_str(&env_key); + out.push('='); + out.push_str(&rendered); + out.push('\n'); + } + } + + Ok(out) + } +} + +#[cfg(feature = "config")] +fn sample_sections() -> [(&'static str, &'static [&'static str]); 5] { + [ + ("# --- Server ---", &["scheme", "host", "port"]), + ("# --- User Identity ---", &["default_user_id"]), + ( + "# --- Security ---", + &[ + "require_state", + "generate_client_secret_for_dcr", + "allowed_origins", + ], + ), + ( + "# --- Token Lifetimes (seconds) ---", + &[ + "access_token_expires_in", + "refresh_token_expires_in", + "authorization_code_expires_in", + "cleanup_interval_secs", + ], + ), + ( + "# --- OIDC Capabilities ---", + &[ + "scopes_supported", + "claims_supported", + "grant_types_supported", + "response_types_supported", + "token_endpoint_auth_methods_supported", + "code_challenge_methods_supported", + "subject_types_supported", + "id_token_signing_alg_values_supported", + ], + ), + ] +} + +#[cfg(feature = "config")] +fn sample_inline_comments() -> std::collections::HashMap<&'static str, &'static str> { + [ + ("port", "0 = random free port"), + ("default_user_id", "sub claim in tokens/userinfo"), + ( + "require_state", + "require state param in /authorize", + ), + ( + "generate_client_secret_for_dcr", + "auto-generate secret on DCR", + ), + ("allowed_origins", "CORS (empty = allow all)"), + ("access_token_expires_in", "1 hour"), + ("refresh_token_expires_in", "30 days"), + ("authorization_code_expires_in", "10 minutes"), + ( + "cleanup_interval_secs", + "cleanup expired every 5 min (0 = off)", + ), + ] + .into_iter() + .collect::>() +} + +#[cfg(feature = "config")] +fn config_key_to_env_var(key: &str) -> String { + let mut out = String::with_capacity("OAUTH_".len() + key.len()); + out.push_str("OAUTH_"); + + for ch in key.chars() { + if ch.is_ascii_alphanumeric() { + out.push(ch.to_ascii_uppercase()); + } else { + out.push('_'); + } + } + + out +} + +#[cfg(feature = "config")] +fn render_env_value(value: &serde_yaml::Value) -> Result { + match value { + serde_yaml::Value::Null => Ok(String::new()), + serde_yaml::Value::Bool(v) => Ok(v.to_string()), + serde_yaml::Value::Number(v) => Ok(v.to_string()), + serde_yaml::Value::String(v) => Ok(v.clone()), + serde_yaml::Value::Sequence(items) => { + let parts = items + .iter() + .map(render_env_scalar) + .collect::, _>>()?; + Ok(parts.join(",")) + } + _ => serde_json::to_string(value).map_err(|err| { + ConfigError::SampleGenerationError(format!( + "failed to render env value as JSON: {err}" + )) + }), + } +} + +#[cfg(feature = "config")] +fn render_env_scalar(value: &serde_yaml::Value) -> Result { + match value { + serde_yaml::Value::Bool(v) => Ok(v.to_string()), + serde_yaml::Value::Number(v) => Ok(v.to_string()), + serde_yaml::Value::String(v) => Ok(v.clone()), + serde_yaml::Value::Null => Ok(String::new()), + _ => Err(ConfigError::SampleGenerationError( + "unsupported nested value in sequence while rendering env sample".to_string(), + )), + } +} + +#[cfg(feature = "config")] +fn take_mapping_field( + mapping: &mut serde_yaml::Mapping, + key: &str, +) -> Option { + mapping.remove(serde_yaml::Value::String(key.to_string())) +} + +#[cfg(feature = "config")] +fn append_yaml_field( + out: &mut String, + key: &str, + value: &serde_yaml::Value, + inline_comment: Option<&str>, +) -> Result<(), ConfigError> { + let rendered = serde_yaml::to_string(value) + .map_err(ConfigError::YamlSerializeError)? + .trim_end() + .to_string(); + + if rendered.contains('\n') { + out.push_str(key); + out.push_str(":\n"); + for line in rendered.lines() { + out.push_str(" "); + out.push_str(line); + out.push('\n'); + } + } else { + out.push_str(key); + out.push_str(": "); + out.push_str(&rendered); + if let Some(comment) = inline_comment { + out.push_str(" # "); + out.push_str(comment); + } + out.push('\n'); + } + + Ok(()) } #[cfg(feature = "config")] @@ -312,6 +604,90 @@ pub enum ConfigError { YamlParseError(serde_yaml::Error), #[error("TOML parse error: {0}")] TomlParseError(toml::de::Error), + #[error("YAML serialize error: {0}")] + YamlSerializeError(serde_yaml::Error), #[error("Unsupported config format: {0}")] UnsupportedFormat(String), + #[error("Sample generation error: {0}")] + SampleGenerationError(String), +} + +#[cfg(test)] +mod tests { + use super::IssuerConfig; + use std::collections::HashSet; + + #[cfg(feature = "config")] + #[test] + fn sample_yaml_includes_all_configurable_keys() { + let sample_yaml = IssuerConfig::to_sample_yaml().unwrap(); + let sample_value: serde_yaml::Value = serde_yaml::from_str(&sample_yaml).unwrap(); + let sample_mapping = sample_value.as_mapping().unwrap(); + + let defaults = IssuerConfig { + port: 8090, + ..Default::default() + }; + let default_value = serde_yaml::to_value(defaults).unwrap(); + let default_mapping = default_value.as_mapping().unwrap(); + + let sample_keys = sample_mapping + .keys() + .filter_map(|value| value.as_str().map(|s| s.to_string())) + .collect::>(); + let default_keys = default_mapping + .keys() + .filter_map(|value| value.as_str().map(|s| s.to_string())) + .collect::>(); + + assert_eq!(sample_keys, default_keys); + } + + #[cfg(feature = "config")] + #[test] + fn sample_yaml_contains_expected_comment_headers() { + let sample_yaml = IssuerConfig::to_sample_yaml().unwrap(); + assert!(sample_yaml.contains("# OAuth2 Test Server — Sample Configuration")); + assert!(sample_yaml.contains("# --- Server ---")); + assert!(sample_yaml.contains("# --- OIDC Capabilities ---")); + } + + #[cfg(feature = "config")] + #[test] + fn sample_env_contains_expected_comment_headers() { + let sample_env = IssuerConfig::to_sample_env().unwrap(); + assert!(sample_env.contains("# OAuth2 Test Server — Sample Environment Configuration")); + assert!(sample_env.contains("# --- Server ---")); + assert!(sample_env.contains("# --- OIDC Capabilities ---")); + } + + #[cfg(feature = "config")] + #[test] + fn sample_env_includes_all_configurable_keys() { + let sample_env = IssuerConfig::to_sample_env().unwrap(); + let sample_keys = sample_env + .lines() + .filter_map(|line| { + let line = line.trim(); + if line.is_empty() || line.starts_with('#') { + return None; + } + line.split_once('=').map(|(key, _)| key.to_string()) + }) + .collect::>(); + + let defaults = IssuerConfig { + port: 8090, + ..Default::default() + }; + let default_value = serde_yaml::to_value(defaults).unwrap(); + let default_mapping = default_value.as_mapping().unwrap(); + + let default_env_keys = default_mapping + .keys() + .filter_map(|value| value.as_str().map(super::config_key_to_env_var)) + .collect::>(); + + assert_eq!(sample_keys, default_env_keys); + } } diff --git a/src/main.rs b/src/main.rs index cd76e97..3b2e59c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,13 +1,339 @@ use colored::Colorize; use oauth2_test_server::{IssuerConfig, OAuthTestServer}; +use std::path::{Path, PathBuf}; +use std::process::ExitCode; + +#[derive(Debug, Default, PartialEq, Eq)] +struct StartupOptions { + config_path: Option, + scheme: Option, + host: Option, + port: Option, + default_user_id: Option, + require_state: Option, + generate_client_secret_for_dcr: Option, + access_token_expires_in: Option, + refresh_token_expires_in: Option, + authorization_code_expires_in: Option, + cleanup_interval_secs: Option, +} + +#[derive(Debug)] +enum ParseResult { + Run(StartupOptions), + Help, + GenerateConfigSampleYaml, + GenerateConfigEnvSample, +} + +fn print_usage(program: &str) { + eprintln!("Usage: {program} [options]"); + eprintln!(); + eprintln!("Options:"); + eprintln!(" --config, -c Load config from YAML/TOML file"); + eprintln!(" --scheme Override issuer URL scheme"); + eprintln!(" --host Override bind host"); + eprintln!(" --port Override bind port"); + eprintln!(" --default-user-id Override default user subject"); + eprintln!(" --require-state Override state requirement"); + eprintln!(" --generate-client-secret-for-dcr Override DCR client-secret generation"); + eprintln!(" --access-token-expires-in Override access token TTL"); + eprintln!(" --refresh-token-expires-in Override refresh token TTL"); + eprintln!(" --authorization-code-expires-in Override auth code TTL"); + eprintln!(" --cleanup-interval-secs Override cleanup interval"); + eprintln!(" -generate-config-sample-yaml Generate ./.config.sample.yaml and exit"); + eprintln!(" -generate-config-env-sample Generate ./.config.sample.env and exit"); + eprintln!(" --help, -h Show this help message"); +} + +fn parse_bool(flag: &str, value: &str) -> Result { + match value.to_ascii_lowercase().as_str() { + "true" | "1" | "yes" => Ok(true), + "false" | "0" | "no" => Ok(false), + _ => Err(format!("invalid boolean for {flag}: {value}")), + } +} + +fn parse_number(flag: &str, value: &str) -> Result +where + T: std::str::FromStr, +{ + value + .parse::() + .map_err(|_| format!("invalid value for {flag}: {value}")) +} + +fn next_arg_value(iter: &mut I, flag: &str) -> Result +where + I: Iterator, +{ + iter.next() + .ok_or_else(|| format!("missing value for {flag}")) +} + +fn parse_startup_options(args: I) -> Result +where + I: IntoIterator, +{ + let mut iter = args.into_iter(); + let _program = iter + .next() + .unwrap_or_else(|| "oauth2-test-server".to_string()); + + let mut options = StartupOptions::default(); + + while let Some(arg) = iter.next() { + match arg.as_str() { + "-h" | "--help" => return Ok(ParseResult::Help), + "-generate-config-sample-yaml" | "--generate-config-sample-yaml" => { + return Ok(ParseResult::GenerateConfigSampleYaml) + } + "-generate-config-env-sample" | "--generate-config-env-sample" => { + return Ok(ParseResult::GenerateConfigEnvSample) + } + "--config" | "-c" => { + let path = next_arg_value(&mut iter, "--config")?; + options.config_path = Some(PathBuf::from(path)); + } + "--scheme" => options.scheme = Some(next_arg_value(&mut iter, "--scheme")?), + "--host" => options.host = Some(next_arg_value(&mut iter, "--host")?), + "--port" => { + let value = next_arg_value(&mut iter, "--port")?; + options.port = Some(parse_number("--port", &value)?); + } + "--default-user-id" => { + options.default_user_id = Some(next_arg_value(&mut iter, "--default-user-id")?) + } + "--require-state" => { + let value = next_arg_value(&mut iter, "--require-state")?; + options.require_state = Some(parse_bool("--require-state", &value)?); + } + "--generate-client-secret-for-dcr" => { + let value = next_arg_value(&mut iter, "--generate-client-secret-for-dcr")?; + options.generate_client_secret_for_dcr = Some(parse_bool( + "--generate-client-secret-for-dcr", + &value, + )?); + } + "--access-token-expires-in" => { + let value = next_arg_value(&mut iter, "--access-token-expires-in")?; + options.access_token_expires_in = + Some(parse_number("--access-token-expires-in", &value)?); + } + "--refresh-token-expires-in" => { + let value = next_arg_value(&mut iter, "--refresh-token-expires-in")?; + options.refresh_token_expires_in = + Some(parse_number("--refresh-token-expires-in", &value)?); + } + "--authorization-code-expires-in" => { + let value = next_arg_value(&mut iter, "--authorization-code-expires-in")?; + options.authorization_code_expires_in = + Some(parse_number("--authorization-code-expires-in", &value)?); + } + "--cleanup-interval-secs" => { + let value = next_arg_value(&mut iter, "--cleanup-interval-secs")?; + options.cleanup_interval_secs = Some(parse_number("--cleanup-interval-secs", &value)?); + } + _ => return Err(format!("unknown argument: {arg}")), + } + } + + Ok(ParseResult::Run(options)) +} + +fn apply_env_overrides_with(config: &mut IssuerConfig, mut get_env: F) -> Result<(), String> +where + F: FnMut(&str) -> Option, +{ + if let Some(v) = get_env("OAUTH_SCHEME") { + config.scheme = v; + } + if let Some(v) = get_env("OAUTH_HOST") { + config.host = v; + } + if let Some(v) = get_env("OAUTH_PORT") { + config.port = parse_number("OAUTH_PORT", &v)?; + } + if let Some(v) = get_env("OAUTH_DEFAULT_USER_ID") { + config.default_user_id = v; + } + if let Some(v) = get_env("OAUTH_REQUIRE_STATE") { + config.require_state = parse_bool("OAUTH_REQUIRE_STATE", &v)?; + } + if let Some(v) = get_env("OAUTH_GENERATE_CLIENT_SECRET_FOR_DCR") { + config.generate_client_secret_for_dcr = + parse_bool("OAUTH_GENERATE_CLIENT_SECRET_FOR_DCR", &v)?; + } + if let Some(v) = get_env("OAUTH_ACCESS_TOKEN_EXPIRES_IN") { + config.access_token_expires_in = parse_number("OAUTH_ACCESS_TOKEN_EXPIRES_IN", &v)?; + } + if let Some(v) = get_env("OAUTH_REFRESH_TOKEN_EXPIRES_IN") { + config.refresh_token_expires_in = parse_number("OAUTH_REFRESH_TOKEN_EXPIRES_IN", &v)?; + } + if let Some(v) = get_env("OAUTH_AUTHORIZATION_CODE_EXPIRES_IN") { + config.authorization_code_expires_in = + parse_number("OAUTH_AUTHORIZATION_CODE_EXPIRES_IN", &v)?; + } + if let Some(v) = get_env("OAUTH_CLEANUP_INTERVAL_SECS") { + config.cleanup_interval_secs = parse_number("OAUTH_CLEANUP_INTERVAL_SECS", &v)?; + } + + Ok(()) +} + +fn apply_cli_overrides(config: &mut IssuerConfig, options: &StartupOptions) { + if let Some(v) = &options.scheme { + config.scheme = v.clone(); + } + if let Some(v) = &options.host { + config.host = v.clone(); + } + if let Some(v) = options.port { + config.port = v; + } + if let Some(v) = &options.default_user_id { + config.default_user_id = v.clone(); + } + if let Some(v) = options.require_state { + config.require_state = v; + } + if let Some(v) = options.generate_client_secret_for_dcr { + config.generate_client_secret_for_dcr = v; + } + if let Some(v) = options.access_token_expires_in { + config.access_token_expires_in = v; + } + if let Some(v) = options.refresh_token_expires_in { + config.refresh_token_expires_in = v; + } + if let Some(v) = options.authorization_code_expires_in { + config.authorization_code_expires_in = v; + } + if let Some(v) = options.cleanup_interval_secs { + config.cleanup_interval_secs = v; + } +} + +fn load_config_with(options: &StartupOptions, get_env: F) -> Result +where + F: FnMut(&str) -> Option, +{ + let mut config = if let Some(path) = &options.config_path { + #[cfg(feature = "config")] + { + IssuerConfig::from_file(path) + .map_err(|err| format!("failed to load config file {}: {err}", path.display()))? + } + + #[cfg(not(feature = "config"))] + { + return Err( + "this binary was built without the 'config' feature; --config is unavailable" + .to_string(), + ); + } + } else { + IssuerConfig { + port: 8090, + ..Default::default() + } + }; + + apply_env_overrides_with(&mut config, get_env)?; + apply_cli_overrides(&mut config, options); + + Ok(config) +} + +fn load_config(options: &StartupOptions) -> Result { + load_config_with(options, |key| std::env::var(key).ok()) +} + +#[cfg(feature = "config")] +fn generate_config_sample_file_at(path: &Path) -> Result<(), String> { + if path.exists() { + return Err(format!("{} already exists", path.display())); + } + + let sample = IssuerConfig::to_sample_yaml() + .map_err(|err| format!("failed to generate sample config content: {err}"))?; + std::fs::write(path, sample) + .map_err(|err| format!("failed to write {}: {err}", path.display())) +} + +#[cfg(not(feature = "config"))] +fn generate_config_sample_file_at(_path: &Path) -> Result<(), String> { + Err("this binary was built without the 'config' feature; sample generation is unavailable" + .to_string()) +} + +fn generate_config_sample_file() -> Result<(), String> { + generate_config_sample_file_at(Path::new("./.config.sample.yaml")) +} + +#[cfg(feature = "config")] +fn generate_config_env_sample_file_at(path: &Path) -> Result<(), String> { + if path.exists() { + return Err(format!("{} already exists", path.display())); + } + + let sample = IssuerConfig::to_sample_env() + .map_err(|err| format!("failed to generate sample env config content: {err}"))?; + std::fs::write(path, sample) + .map_err(|err| format!("failed to write {}: {err}", path.display())) +} + +#[cfg(not(feature = "config"))] +fn generate_config_env_sample_file_at(_path: &Path) -> Result<(), String> { + Err("this binary was built without the 'config' feature; sample generation is unavailable" + .to_string()) +} + +fn generate_config_env_sample_file() -> Result<(), String> { + generate_config_env_sample_file_at(Path::new("./.config.sample.env")) +} #[tokio::main] -async fn main() { +async fn main() -> ExitCode { tracing_subscriber::fmt::init(); - let config = IssuerConfig { - port: 8090, - ..Default::default() + + let startup = match parse_startup_options(std::env::args()) { + Ok(ParseResult::Run(startup)) => startup, + Ok(ParseResult::Help) => { + print_usage("oauth2-test-server"); + return ExitCode::SUCCESS; + } + Ok(ParseResult::GenerateConfigSampleYaml) => { + if let Err(err) = generate_config_sample_file() { + eprintln!("{err}"); + return ExitCode::from(2); + } + println!("Generated ./.config.sample.yaml"); + return ExitCode::SUCCESS; + } + Ok(ParseResult::GenerateConfigEnvSample) => { + if let Err(err) = generate_config_env_sample_file() { + eprintln!("{err}"); + return ExitCode::from(2); + } + println!("Generated ./.config.sample.env"); + return ExitCode::SUCCESS; + } + Err(err) => { + eprintln!("{err}"); + print_usage("oauth2-test-server"); + return ExitCode::from(2); + } + }; + + let config = match load_config(&startup) { + Ok(config) => config, + Err(err) => { + eprintln!("{err}"); + return ExitCode::from(2); + } }; + let server = OAuthTestServer::start_with_config(config).await; println!( @@ -40,17 +366,274 @@ async fn main() { if let Err(err) = server.wait_for_shutdown().await { eprintln!("{err}"); + return ExitCode::from(1); } + + ExitCode::SUCCESS } #[cfg(test)] mod tests { + use super::{ + apply_env_overrides_with, generate_config_env_sample_file_at, + generate_config_sample_file_at, load_config_with, parse_startup_options, ParseResult, + StartupOptions, + }; use base64::{engine::general_purpose, Engine}; use oauth2_test_server::models::IdTokenClaims; use oauth2_test_server::testkit::AuthorizeParams; + use oauth2_test_server::IssuerConfig; use reqwest::StatusCode; + #[test] + fn parse_startup_options_accepts_config_path() { + let args = vec![ + "oauth2-test-server".to_string(), + "--config".to_string(), + "./config.sample.yaml".to_string(), + ]; + + let parsed = parse_startup_options(args).unwrap(); + let ParseResult::Run(options) = parsed else { + panic!("expected ParseResult::Run"); + }; + + assert_eq!( + options, + StartupOptions { + config_path: Some(std::path::PathBuf::from("./config.sample.yaml")), + ..Default::default() + } + ); + } + + #[test] + fn parse_startup_options_rejects_missing_config_value() { + let args = vec!["oauth2-test-server".to_string(), "--config".to_string()]; + let err = parse_startup_options(args).unwrap_err(); + assert!(err.contains("missing value for --config")); + } + + #[test] + fn parse_startup_options_rejects_unknown_argument() { + let args = vec!["oauth2-test-server".to_string(), "--bogus".to_string()]; + let err = parse_startup_options(args).unwrap_err(); + assert!(err.contains("unknown argument")); + } + + #[test] + fn parse_startup_options_accepts_scalar_overrides() { + let args = vec![ + "oauth2-test-server".to_string(), + "--port".to_string(), + "8088".to_string(), + "--require-state".to_string(), + "false".to_string(), + "--default-user-id".to_string(), + "alice".to_string(), + "--access-token-expires-in".to_string(), + "7200".to_string(), + ]; + + let parsed = parse_startup_options(args).unwrap(); + let ParseResult::Run(options) = parsed else { + panic!("expected ParseResult::Run"); + }; + + assert_eq!(options.port, Some(8088)); + assert_eq!(options.require_state, Some(false)); + assert_eq!(options.default_user_id.as_deref(), Some("alice")); + assert_eq!(options.access_token_expires_in, Some(7200)); + } + + #[test] + fn parse_startup_options_rejects_invalid_scalar_override() { + let args = vec![ + "oauth2-test-server".to_string(), + "--port".to_string(), + "not-a-number".to_string(), + ]; + let err = parse_startup_options(args).unwrap_err(); + assert!(err.contains("invalid value for --port")); + } + + #[test] + fn parse_startup_options_accepts_generate_config_sample_yaml_flag() { + let args = vec![ + "oauth2-test-server".to_string(), + "-generate-config-sample-yaml".to_string(), + ]; + + let parsed = parse_startup_options(args).unwrap(); + assert!(matches!(parsed, ParseResult::GenerateConfigSampleYaml)); + } + + #[test] + fn parse_startup_options_accepts_generate_config_env_sample_flag() { + let args = vec![ + "oauth2-test-server".to_string(), + "-generate-config-env-sample".to_string(), + ]; + + let parsed = parse_startup_options(args).unwrap(); + assert!(matches!(parsed, ParseResult::GenerateConfigEnvSample)); + } + + #[cfg(feature = "config")] + #[test] + fn generate_config_sample_file_at_creates_file_and_rejects_existing_path() { + let temp_dir = std::env::temp_dir().join(format!( + "oauth2-test-server-config-sample-{}", + uuid::Uuid::new_v4() + )); + std::fs::create_dir_all(&temp_dir).unwrap(); + let file_path = temp_dir.join(".config.sample.yaml"); + + generate_config_sample_file_at(&file_path).unwrap(); + + let created = std::fs::read_to_string(&file_path).unwrap(); + assert!(created.contains("# OAuth2 Test Server — Sample Configuration")); + assert!(created.contains("scheme:")); + assert!(created.contains("id_token_signing_alg_values_supported:")); + + let err = generate_config_sample_file_at(&file_path).unwrap_err(); + assert!(err.contains("already exists")); + + let _ = std::fs::remove_file(&file_path); + let _ = std::fs::remove_dir_all(&temp_dir); + } + + #[cfg(feature = "config")] + #[test] + fn generate_config_env_sample_file_at_creates_file_and_rejects_existing_path() { + let temp_dir = std::env::temp_dir().join(format!( + "oauth2-test-server-env-config-sample-{}", + uuid::Uuid::new_v4() + )); + std::fs::create_dir_all(&temp_dir).unwrap(); + let file_path = temp_dir.join(".config.sample.env"); + + generate_config_env_sample_file_at(&file_path).unwrap(); + + let created = std::fs::read_to_string(&file_path).unwrap(); + assert!(created.contains("# OAuth2 Test Server — Sample Environment Configuration")); + assert!(created.contains("OAUTH_SCHEME=")); + assert!(created.contains("OAUTH_ID_TOKEN_SIGNING_ALG_VALUES_SUPPORTED=")); + + let err = generate_config_env_sample_file_at(&file_path).unwrap_err(); + assert!(err.contains("already exists")); + + let _ = std::fs::remove_file(&file_path); + let _ = std::fs::remove_dir_all(&temp_dir); + } + + #[test] + fn load_config_uses_defaults_when_no_path_is_provided() { + let config = load_config_with(&StartupOptions::default(), |_| None).unwrap(); + assert_eq!(config.port, 8090); + } + + #[cfg(feature = "config")] + #[test] + fn load_config_reads_yaml_file() { + let temp_path = std::env::temp_dir().join(format!( + "oauth2-test-server-config-{}.yaml", + uuid::Uuid::new_v4() + )); + std::fs::write(&temp_path, "port: 4711\ndefault_user_id: from-file\n").unwrap(); + + let options = StartupOptions { + config_path: Some(temp_path.clone()), + ..Default::default() + }; + let config = load_config_with(&options, |_| None).unwrap(); + + assert_eq!(config.port, 4711); + assert_eq!(config.default_user_id, "from-file"); + + let _ = std::fs::remove_file(temp_path); + } + + #[test] + fn env_overrides_apply_only_when_present() { + let mut config = IssuerConfig { + host: "from-file".to_string(), + port: 8000, + require_state: true, + ..Default::default() + }; + + apply_env_overrides_with(&mut config, |key| match key { + "OAUTH_HOST" => Some("from-env".to_string()), + "OAUTH_REQUIRE_STATE" => Some("false".to_string()), + _ => None, + }) + .unwrap(); + + assert_eq!(config.host, "from-env"); + assert_eq!(config.port, 8000); + assert!(!config.require_state); + } + + #[test] + fn env_overrides_reject_invalid_values() { + let mut config = IssuerConfig::default(); + let err = apply_env_overrides_with(&mut config, |key| match key { + "OAUTH_PORT" => Some("bad-port".to_string()), + _ => None, + }) + .unwrap_err(); + + assert!(err.contains("invalid value for OAUTH_PORT")); + } + + #[cfg(feature = "config")] + #[test] + fn load_config_applies_precedence_cli_over_env_over_file() { + let temp_path = std::env::temp_dir().join(format!( + "oauth2-test-server-config-precedence-{}.yaml", + uuid::Uuid::new_v4() + )); + std::fs::write( + &temp_path, + "port: 4711\ndefault_user_id: from-file\nrequire_state: true\n", + ) + .unwrap(); + + let options = StartupOptions { + config_path: Some(temp_path.clone()), + port: Some(7777), + ..Default::default() + }; + + let config = load_config_with(&options, |key| match key { + "OAUTH_PORT" => Some("9000".to_string()), + "OAUTH_DEFAULT_USER_ID" => Some("from-env".to_string()), + "OAUTH_REQUIRE_STATE" => Some("false".to_string()), + _ => None, + }) + .unwrap(); + + assert_eq!(config.port, 7777); + assert_eq!(config.default_user_id, "from-env"); + assert!(!config.require_state); + + let _ = std::fs::remove_file(temp_path); + } + + #[test] + fn load_config_rejects_invalid_env_values() { + let options = StartupOptions::default(); + let err = load_config_with(&options, |key| match key { + "OAUTH_ACCESS_TOKEN_EXPIRES_IN" => Some("oops".to_string()), + _ => None, + }) + .unwrap_err(); + + assert!(err.contains("invalid value for OAUTH_ACCESS_TOKEN_EXPIRES_IN")); + } + #[tokio::test] async fn test_id_token_in_auth_code_flow() { let server = oauth2_test_server::OAuthTestServer::start().await; diff --git a/tests/binary_config.rs b/tests/binary_config.rs new file mode 100644 index 0000000..93724f2 --- /dev/null +++ b/tests/binary_config.rs @@ -0,0 +1,63 @@ +use std::fs; +use std::process::{Command, Stdio}; +use std::thread; +use std::time::Duration; + +#[test] +fn binary_starts_with_config_file() { + let tmp_path = std::env::temp_dir().join(format!( + "oauth2-test-server-config-{}.yaml", + std::process::id() + )); + + fs::write( + &tmp_path, + "scheme: http\nhost: 127.0.0.1\nport: 0\ndefault_user_id: test-user\n", + ) + .expect("failed to write temporary config file"); + + let mut child = Command::new(env!("CARGO_BIN_EXE_oauth2-test-server")) + .arg("--config") + .arg(&tmp_path) + .stdout(Stdio::null()) + .stderr(Stdio::piped()) + .spawn() + .expect("failed to start oauth2-test-server binary"); + + thread::sleep(Duration::from_millis(400)); + + if let Some(status) = child + .try_wait() + .expect("failed to check oauth2-test-server status") + { + let output = child + .wait_with_output() + .expect("failed to collect process output"); + panic!( + "expected process to stay running, exited with status {status}: {}", + String::from_utf8_lossy(&output.stderr) + ); + } + + let _ = child.kill(); + let _ = child.wait(); + let _ = fs::remove_file(tmp_path); +} + +#[test] +fn binary_fails_on_missing_config_file() { + let missing_path = std::env::temp_dir().join(format!( + "oauth2-test-server-missing-{}.yaml", + std::process::id() + )); + + let output = Command::new(env!("CARGO_BIN_EXE_oauth2-test-server")) + .arg("--config") + .arg(&missing_path) + .output() + .expect("failed to run oauth2-test-server binary"); + + assert!(!output.status.success()); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!(stderr.contains("failed to load config file")); +} diff --git a/tests/config.rs b/tests/config.rs index 896ecef..0c7468d 100644 --- a/tests/config.rs +++ b/tests/config.rs @@ -1,4 +1,5 @@ use oauth2_test_server::IssuerConfig; +use std::collections::BTreeSet; #[test] fn test_config_from_yaml() { @@ -82,3 +83,36 @@ fn test_config_sample_file() { assert_eq!(config.subject_types_supported, vec!["public"]); assert_eq!(config.id_token_signing_alg_values_supported, vec!["RS256"]); } + +#[test] +fn test_config_sample_includes_all_supported_fields() { + let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("config.sample.yaml"); + let sample_contents = std::fs::read_to_string(path).unwrap(); + + let sample_yaml: serde_yaml::Value = serde_yaml::from_str(&sample_contents).unwrap(); + let sample_keys: BTreeSet = sample_yaml + .as_mapping() + .unwrap() + .keys() + .filter_map(|k| k.as_str().map(str::to_string)) + .collect(); + + let default_yaml = serde_yaml::to_value(IssuerConfig::default()).unwrap(); + let supported_keys: BTreeSet = default_yaml + .as_mapping() + .unwrap() + .keys() + .filter_map(|k| k.as_str().map(str::to_string)) + .collect(); + + let missing: Vec = supported_keys + .difference(&sample_keys) + .cloned() + .collect(); + + assert!( + missing.is_empty(), + "config.sample.yaml is missing supported keys: {}", + missing.join(", ") + ); +}