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
588 changes: 194 additions & 394 deletions Cargo.lock

Large diffs are not rendered by default.

62 changes: 61 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -254,7 +254,67 @@ Quick test with curl:
}'
```

## How to Use in Tests
## Configuration

The server is configured via [`IssuerConfig`](https://docs.rs/oauth2-test-server/latest/oauth2_test_server/config/struct.IssuerConfig.html).

### Default User

The server uses a hardcoded user identity for all authorization requests. By default this is `"test-user-123"`.

Set it programmatically:

```rust
use oauth2_test_server::IssuerConfig;

let config = IssuerConfig {
default_user_id: "alice".into(),
port: 0,
..Default::default()
};
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:

```rust
// From environment variables (requires "config" feature)
let config = IssuerConfig::from_env()?;

// From a YAML or TOML file (requires "config" feature)
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).

### All Configuration Options

| Field | Env Var | Default | Description |
|-------|---------|---------|-------------|
| `scheme` | `OAUTH_SCHEME` | `http` | URL scheme |
| `host` | `OAUTH_HOST` | `localhost` | Bind host |
| `port` | `OAUTH_PORT` | `8090` | Listen port (`0` = random) |
| `default_user_id` | `OAUTH_DEFAULT_USER_ID` | `test-user-123` | Default `sub` claim when no user is logged in |
| `require_state` | `OAUTH_REQUIRE_STATE` | `true` | Require `state` param in auth requests |
| `generate_client_secret_for_dcr` | `OAUTH_GENERATE_CLIENT_SECRET_FOR_DCR` | `true` | Auto-generate client secret on DCR |
| `access_token_expires_in` | `OAUTH_ACCESS_TOKEN_EXPIRES_IN` | `3600` | Access token TTL (seconds) |
| `refresh_token_expires_in` | `OAUTH_REFRESH_TOKEN_EXPIRES_IN` | `2592000` | Refresh token TTL (seconds, 30 days) |
| `authorization_code_expires_in` | `OAUTH_AUTHORIZATION_CODE_EXPIRES_IN` | `600` | Auth code TTL (seconds, 10 min) |
| `cleanup_interval_secs` | `OAUTH_CLEANUP_INTERVAL_SECS` | `300` | Expired entry cleanup interval (`0` = disable) |
| `allowed_origins` | `OAUTH_ALLOWED_ORIGINS` | `[]` | CORS origins (empty = allow all) |

### Loading Order

1. Programmatic `IssuerConfig` (highest priority)
2. Environment variables (`OAUTH_*`)
3. YAML/TOML config file (detected by extension: `.yaml`, `.yml`, `.toml`)
4. Built-in defaults (lowest priority)

The `from_env` and `from_file` methods are available when the `config` feature is enabled (included by default).

## How to Use in Tests

### Quick Start
```rust
Expand Down
69 changes: 69 additions & 0 deletions config.sample.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
# OAuth2 Test Server — Sample Configuration
# Copy this file, edit the values, and load it via
# IssuerConfig::from_file("path/to/config.yaml")
#
# All fields are optional — defaults are shown below.

# --- Server ---
scheme: "http"
host: "localhost"
port: 8090 # 0 = random free port

# --- User Identity ---
default_user_id: "test-user-123" # sub claim in tokens/userinfo

# --- Security ---
require_state: true # require state param in /authorize
generate_client_secret_for_dcr: true # auto-generate secret on DCR
allowed_origins: [] # CORS (empty = allow all)

# --- Token Lifetimes (seconds) ---
access_token_expires_in: 3600 # 1 hour
refresh_token_expires_in: 2592000 # 30 days
authorization_code_expires_in: 600 # 10 minutes
cleanup_interval_secs: 300 # cleanup expired every 5 min (0 = off)

# --- OIDC Capabilities ---
scopes_supported:
- openid
- profile
- email
- offline_access
- address
- phone

claims_supported:
- sub
- name
- given_name
- family_name
- email
- email_verified
- picture
- locale

grant_types_supported:
- authorization_code
- refresh_token
- client_credentials

response_types_supported:
- code
- token
- id_token

token_endpoint_auth_methods_supported:
- client_secret_basic
- client_secret_post
- none
- private_key_jwt

code_challenge_methods_supported:
- plain
- S256

subject_types_supported:
- public

id_token_signing_alg_values_supported:
- RS256
21 changes: 21 additions & 0 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,27 @@
//! # • Revoke: http://127.0.0.1:8090/revoke
//! ```
//!
//! ## Configuration
//!
//! The server is configured via [`IssuerConfig`]. Set `default_user_id` to
//! change the user identity used in authorization flows:
//!
//! ```rust,no_run
//! use oauth2_test_server::IssuerConfig;
//!
//! let config = IssuerConfig {
//! default_user_id: "alice".into(),
//! port: 0,
//! ..Default::default()
//! };
//! ```
//!
//! All fields can also be loaded from environment variables prefixed with
//! `OAUTH_` (e.g. `OAUTH_DEFAULT_USER_ID=alice`) via [`IssuerConfig::from_env`],
//! or from a YAML/TOML file via [`IssuerConfig::from_file`].
//! See the [README](https://github.com/rust-mcp-stack/oauth2-test-server#configuration)
//! for a full list of options.
//!
//! ## Example Usage
//!
//! ```bash
Expand Down
35 changes: 35 additions & 0 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,41 @@ mod tests {
assert!(claims.c_hash.is_some());
}

#[tokio::test]
async fn test_id_token_omits_nonce_when_not_sent() {
let server = oauth2_test_server::OAuthTestServer::start().await;

let client = server
.register_client(serde_json::json!({
"scope": "openid",
"redirect_uris": ["http://localhost:8080/callback"],
}))
.await;

let pkce = server.pkce_pair();

let token = server
.complete_auth_flow(
&client,
AuthorizeParams::new()
.redirect_uri("http://localhost:8080/callback")
.scope("openid")
.pkce(pkce.clone()),
"test-user",
)
.await;

let id_token = token["id_token"].as_str().unwrap();
let parts: Vec<&str> = id_token.split('.').collect();
let payload_bytes = general_purpose::URL_SAFE_NO_PAD.decode(parts[1]).unwrap();
let payload_str = std::str::from_utf8(&payload_bytes).unwrap();

assert!(
!payload_str.contains("\"nonce\""),
"nonce key should be absent when no nonce was sent, got: {payload_str}"
);
}

#[tokio::test]
async fn test_no_id_token_without_openid_scope() {
let server = oauth2_test_server::OAuthTestServer::start().await;
Expand Down
8 changes: 8 additions & 0 deletions src/models.rs
Original file line number Diff line number Diff line change
Expand Up @@ -83,20 +83,28 @@ pub struct IdTokenClaims {
/// Issued at time
pub iat: usize,
/// Authentication time
#[serde(skip_serializing_if = "Option::is_none")]
pub auth_time: Option<usize>,
/// Nonce value from authorization request (must be echoed if present)
#[serde(skip_serializing_if = "Option::is_none")]
pub nonce: Option<String>,
/// Access token hash (at_hash) - OIDC Core Section 3.2.2.9
#[serde(skip_serializing_if = "Option::is_none")]
pub at_hash: Option<String>,
/// Authorization code hash (c_hash) - OIDC Core Section 3.2.2.9
#[serde(skip_serializing_if = "Option::is_none")]
pub c_hash: Option<String>,
/// Authorized party (client_id)
#[serde(skip_serializing_if = "Option::is_none")]
pub azp: Option<String>,
/// Token type
#[serde(skip_serializing_if = "Option::is_none")]
pub typ: Option<String>,
/// Session ID
#[serde(skip_serializing_if = "Option::is_none")]
pub sid: Option<String>,
/// JWT ID
#[serde(skip_serializing_if = "Option::is_none")]
pub jti: Option<String>,
/// User claims (name, email, etc.)
#[serde(flatten)]
Expand Down
30 changes: 30 additions & 0 deletions tests/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -52,3 +52,33 @@ fn test_config_defaults() {
assert_eq!(config.access_token_expires_in, 3600);
assert_eq!(config.cleanup_interval_secs, 300);
}

#[test]
fn test_config_sample_file() {
let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("config.sample.yaml");
let config = IssuerConfig::from_file(&path).unwrap();

assert_eq!(config.scheme, "http");
assert_eq!(config.host, "localhost");
assert_eq!(config.port, 8090);
assert_eq!(config.default_user_id, "test-user-123");
assert!(config.require_state);
assert!(config.generate_client_secret_for_dcr);
assert!(config.allowed_origins.is_empty());
assert_eq!(config.access_token_expires_in, 3600);
assert_eq!(config.refresh_token_expires_in, 2592000);
assert_eq!(config.authorization_code_expires_in, 600);
assert_eq!(config.cleanup_interval_secs, 300);

assert!(config.scopes_supported.contains("openid"));
assert!(config.scopes_supported.contains("email"));
assert!(config.claims_supported.contains(&"email".to_string()));
assert!(config.grant_types_supported.contains("authorization_code"));
assert!(config.response_types_supported.contains("code"));
assert!(config
.token_endpoint_auth_methods_supported
.contains("none"));
assert!(config.code_challenge_methods_supported.contains("S256"));
assert_eq!(config.subject_types_supported, vec!["public"]);
assert_eq!(config.id_token_signing_alg_values_supported, vec!["RS256"]);
}
78 changes: 78 additions & 0 deletions tests/crypto.rs
Original file line number Diff line number Diff line change
Expand Up @@ -99,4 +99,82 @@ mod tests {
assert_eq!(claims.c_hash, None);
assert_eq!(claims.typ, Some("IDToken".to_string()));
}

#[test]
fn test_id_token_omits_nonce_key_when_not_provided() {
let keys = Keys::generate();

let id_token = issue_id_token(
"http://localhost:8090",
"test-client-id",
"test-user-id",
None,
None,
None,
3600,
serde_json::json!({}),
&keys,
)
.unwrap();

let parts: Vec<&str> = id_token.split('.').collect();
let payload_bytes = general_purpose::URL_SAFE_NO_PAD.decode(parts[1]).unwrap();
let payload_str = String::from_utf8(payload_bytes).unwrap();
let payload: serde_json::Value = serde_json::from_str(&payload_str).unwrap();

assert!(
!payload_str.contains("\"nonce\""),
"nonce key should be absent, got: {payload_str}"
);
assert!(
!payload_str.contains("\"at_hash\""),
"at_hash key should be absent, got: {payload_str}"
);
assert!(
!payload_str.contains("\"c_hash\""),
"c_hash key should be absent, got: {payload_str}"
);
assert!(payload.get("nonce").is_none());
assert!(payload.get("at_hash").is_none());
assert!(payload.get("c_hash").is_none());
}

#[test]
fn test_id_token_includes_nonce_key_when_provided() {
let keys = Keys::generate();

let id_token = issue_id_token(
"http://localhost:8090",
"test-client-id",
"test-user-id",
Some("test-nonce"),
Some("at_hash_value"),
Some("c_hash_value"),
3600,
serde_json::json!({}),
&keys,
)
.unwrap();

let parts: Vec<&str> = id_token.split('.').collect();
let payload_bytes = general_purpose::URL_SAFE_NO_PAD.decode(parts[1]).unwrap();
let payload_str = String::from_utf8(payload_bytes).unwrap();
let payload: serde_json::Value = serde_json::from_str(&payload_str).unwrap();

assert!(
payload_str.contains("\"nonce\""),
"nonce key should be present"
);
assert!(
payload_str.contains("\"at_hash\""),
"at_hash key should be present"
);
assert!(
payload_str.contains("\"c_hash\""),
"c_hash key should be present"
);
assert_eq!(payload["nonce"], "test-nonce");
assert_eq!(payload["at_hash"], "at_hash_value");
assert_eq!(payload["c_hash"], "c_hash_value");
}
}