Skip to content
Open
Show file tree
Hide file tree
Changes from 2 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
4 changes: 3 additions & 1 deletion docs/cedarling/reference/cedarling-policy-store.md
Original file line number Diff line number Diff line change
Expand Up @@ -228,6 +228,7 @@ Each trusted issuer file includes:
- **`name`**: Human-readable name for the issuer (used as namespace for `TrustedIssuer` entity).
- **`description`**: Optional description of the issuer.
- **`openid_configuration_endpoint`**: HTTPS URL for the OpenID Connect discovery endpoint. For backward compatibility, `configuration_endpoint` is also accepted.
- **`issuer`**: Base URL of the issuer, used when `openid_configuration_endpoint` is not given: the discovery endpoint is then `<issuer>/.well-known/openid-configuration`. Any path on the issuer is kept, so `https://jans.example.com/realms/jans` resolves to `https://jans.example.com/realms/jans/.well-known/openid-configuration`. One of `openid_configuration_endpoint` or `issuer` is required, and `openid_configuration_endpoint` wins when both are present.
- **`token_metadata`**: Map of token names to their metadata configuration (see [Token Metadata Schema](#token-metadata-schema)).
- **`id`**: Optional issuer ID at the top level. If absent, the ID is derived from the filename with the `.json` suffix removed.

Expand Down Expand Up @@ -523,7 +524,8 @@ This record contains the information needed to validate tokens from this issuer:

- **name** : (_String_) The name of the trusted issuer.
- **description** : (_String_) A brief description of the trusted issuer, providing context for administrators.
- **openid_configuration_endpoint** : (_String_) The HTTPS URL for the OpenID Connect configuration endpoint (usually found at `/.well-known/openid-configuration`).
- **openid_configuration_endpoint** : (_String_) The HTTPS URL for the OpenID Connect configuration endpoint (usually found at `/.well-known/openid-configuration`). Takes precedence over `issuer` when both are given.
- **issuer** : (_String_) The base URL of the issuer. Supply this instead of `openid_configuration_endpoint` to have the discovery endpoint derived as `<issuer>/.well-known/openid-configuration`. One of the two is required.
- **trusted_issuer_id** : (_Object_, _optional_) Metadata related to a particular issuer. You can add as many trusted issuers you want. Furthermore, the name this object is what will be used as the entity ID of the [Trusted Issuer](./cedarling-entities.md#trusted-issuer) that Cedarling automatically creates at startup.
- **token_metadata** : (_Object_, _optional_) Tokens metadata in a map of _token name_ -> _token metadata_. See [Token Metadata Schema](#token-metadata-schema).

Expand Down
15 changes: 15 additions & 0 deletions jans-cedarling/cedarling/src/common/policy_store.rs
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,21 @@ impl TrustedIssuer {
}
}

/// The well-known path an `OpenID` Connect provider serves its discovery document from.
const OIDC_DISCOVERY_PATH: &str = ".well-known/openid-configuration";

/// Derives a trusted issuer's `OpenID` configuration endpoint from its base url.
///
/// Follows `OpenID` Connect Discovery: the well-known path is appended to the issuer, keeping any
/// path the issuer carries, so `https://host/realms/jans` resolves to
/// `https://host/realms/jans/.well-known/openid-configuration`.
pub(crate) fn derive_oidc_endpoint(issuer: &str) -> Result<Url, url::ParseError> {
Url::parse(&format!(
"{}/{OIDC_DISCOVERY_PATH}",
issuer.trim_end_matches('/')
))
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
}

/// Container for compiled Cedar policies and their descriptions.
#[derive(Debug, Clone)]
pub(crate) struct PoliciesContainer {
Expand Down
8 changes: 8 additions & 0 deletions jans-cedarling/cedarling/src/common/policy_store/errors.rs
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,14 @@ pub(crate) enum TrustedIssuerErrorType {
reason: String,
},

/// Invalid issuer base URL, from which the OIDC endpoint is derived
#[error("Issuer '{issuer_id}': invalid issuer URL '{url}': {reason}")]
InvalidIssuerUrl {
issuer_id: String,
url: String,
reason: String,
},

/// Token metadata is not an object
#[error("Issuer '{issuer_id}': token_metadata must be a JSON object")]
TokenMetadataNotAnObject { issuer_id: String },
Expand Down
115 changes: 104 additions & 11 deletions jans-cedarling/cedarling/src/common/policy_store/issuer_parser.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
//! ensuring they conform to the required schema with proper token metadata and required fields.

use super::errors::{PolicyStoreError, TrustedIssuerErrorType};
use super::{TokenEntityMetadata, TrustedIssuer};
use super::{TokenEntityMetadata, TrustedIssuer, derive_oidc_endpoint};
use serde_json::Value as JsonValue;
use std::collections::HashMap;
use url::Url;
Expand Down Expand Up @@ -80,28 +80,43 @@ impl IssuerParser {
.and_then(|v| v.as_str())
.unwrap_or("");

let oidc_endpoint_str = obj
let explicit_endpoint = obj
.get("openid_configuration_endpoint") // canonical and more readable key
.or_else(|| obj.get("configuration_endpoint")) // key that was used in RFC
.and_then(|v| v.as_str())
.ok_or_else(|| PolicyStoreError::TrustedIssuerError {
file: filename.to_string(),
err: TrustedIssuerErrorType::MissingRequiredField {
issuer_id: issuer_id.clone(),
field: "openid_configuration_endpoint".to_string(),
},
})?;
.and_then(|v| v.as_str());

let oidc_endpoint =
let oidc_endpoint = if let Some(oidc_endpoint_str) = explicit_endpoint {
Url::parse(oidc_endpoint_str).map_err(|e| PolicyStoreError::TrustedIssuerError {
file: filename.to_string(),
err: TrustedIssuerErrorType::InvalidOidcEndpoint {
issuer_id: issuer_id.clone(),
url: oidc_endpoint_str.to_string(),
reason: e.to_string(),
},
})?
} else {
// No discovery endpoint given: derive it from the issuer's base url, which is all an
// issuer that follows OpenID Connect Discovery needs to be identified by.
let issuer_url = obj.get("issuer").and_then(|v| v.as_str()).ok_or_else(|| {
PolicyStoreError::TrustedIssuerError {
file: filename.to_string(),
err: TrustedIssuerErrorType::MissingRequiredField {
issuer_id: issuer_id.clone(),
field: "openid_configuration_endpoint".to_string(),
},
}
})?;

derive_oidc_endpoint(issuer_url).map_err(|e| PolicyStoreError::TrustedIssuerError {
file: filename.to_string(),
err: TrustedIssuerErrorType::InvalidIssuerUrl {
issuer_id: issuer_id.clone(),
url: issuer_url.to_string(),
reason: e.to_string(),
},
})?
};
Comment thread
coderabbitai[bot] marked this conversation as resolved.

// Parse token_metadata (optional but recommended)
let token_metadata = if let Some(metadata_json) = obj.get("token_metadata") {
Self::parse_token_metadata(metadata_json, &issuer_id, filename)?
Expand Down Expand Up @@ -376,6 +391,84 @@ mod tests {
);
}

#[test]
fn test_parse_issuer_with_issuer_base_url() {
let content = r#"{
"name": "Test Issuer",
"description": "Only the issuer base url is given",
"issuer": "https://accounts.test.com"
}"#;

let result = IssuerParser::parse_issuer(content, "issuer3.json");
assert!(result.is_ok(), "Should parse with issuer: {result:?}");

let parsed = result.unwrap();
assert_eq!(
parsed[0].issuer.oidc_endpoint.as_str(),
"https://accounts.test.com/.well-known/openid-configuration"
);
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated

#[test]
fn test_parse_issuer_with_issuer_base_url_keeps_path() {
let content = r#"{
"name": "Test Issuer",
"description": "Issuer with a path and a trailing slash",
"issuer": "https://accounts.test.com/realms/jans/"
}"#;

let result = IssuerParser::parse_issuer(content, "issuer4.json");
assert!(result.is_ok(), "Should parse with issuer: {result:?}");

let parsed = result.unwrap();
assert_eq!(
parsed[0].issuer.oidc_endpoint.as_str(),
"https://accounts.test.com/realms/jans/.well-known/openid-configuration"
);
}

#[test]
fn test_parse_issuer_endpoint_takes_precedence_over_issuer() {
let content = r#"{
"name": "Test Issuer",
"description": "Both fields are given",
"issuer": "https://accounts.test.com",
"openid_configuration_endpoint": "https://accounts.test.com/custom/openid-configuration"
}"#;

let result = IssuerParser::parse_issuer(content, "issuer5.json");
assert!(result.is_ok(), "Should parse with both fields: {result:?}");

let parsed = result.unwrap();
assert_eq!(
parsed[0].issuer.oidc_endpoint.as_str(),
"https://accounts.test.com/custom/openid-configuration"
);
}

#[test]
fn test_parse_issuer_invalid_issuer_url() {
let content = r#"{
"name": "Test",
"description": "Invalid issuer",
"issuer": "not a valid url"
}"#;

let result = IssuerParser::parse_issuer(content, "bad.json");
let err = result.expect_err("Should fail on invalid issuer url");

assert!(
matches!(
&err,
PolicyStoreError::TrustedIssuerError {
file,
err: TrustedIssuerErrorType::InvalidIssuerUrl { issuer_id, url, .. }
} if file == "bad.json" && issuer_id == "bad" && url == "not a valid url"
),
"Expected InvalidIssuerUrl error, got: {err:?}"
);
}

#[test]
fn test_parse_issuer_invalid_url() {
let content = r#"{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ use serde::de::{self, Error};
use serde::{Deserialize, Deserializer};
use url::Url;

use super::derive_oidc_endpoint;
use crate::common::PartitionResult;
use crate::common::cedar_schema::cedar_json::CedarSchemaJson;
use crate::common::default_entities::{
Expand Down Expand Up @@ -82,27 +83,63 @@ impl From<LegacyTokenEntityMetadata> for super::TokenEntityMetadata {
}

#[derive(Debug, Clone, Deserialize, PartialEq)]
#[serde(try_from = "LegacyTrustedIssuerRaw")]
pub(crate) struct LegacyTrustedIssuer {
pub(crate) name: String,
pub(crate) description: String,
oidc_endpoint: Url,
pub(crate) token_metadata: HashMap<String, LegacyTokenEntityMetadata>,
}

/// The on-disk shape of a trusted issuer, before the discovery endpoint is resolved.
///
/// An issuer names its `OpenID` configuration endpoint either directly, or through the `issuer`
/// base url the endpoint is derived from — so both are optional here, and exactly one of them is
/// required by the conversion below.
#[derive(Deserialize)]
struct LegacyTrustedIssuerRaw {
name: String,
description: String,
#[serde(
rename = "openid_configuration_endpoint",
alias = "configuration_endpoint",
deserialize_with = "de_oidc_endpoint_url"
default
)]
oidc_endpoint: Url,
oidc_endpoint: Option<String>,
#[serde(default)]
pub(crate) token_metadata: HashMap<String, LegacyTokenEntityMetadata>,
}
issuer: Option<String>,
#[serde(default)]
token_metadata: HashMap<String, LegacyTokenEntityMetadata>,
}

impl TryFrom<LegacyTrustedIssuerRaw> for LegacyTrustedIssuer {
type Error = String;

fn try_from(raw: LegacyTrustedIssuerRaw) -> Result<Self, Self::Error> {
let oidc_endpoint = match (raw.oidc_endpoint.as_deref(), raw.issuer.as_deref()) {
// An explicitly configured endpoint always wins: it is the exact url to fetch, and an
// issuer is free to serve its discovery document from somewhere else.
(Some(url), _) => Url::parse(url).map_err(|_| {
"the `\"openid_configuration_endpoint\"` or `\"configuration_endpoint\"` is not a valid url"
.to_string()
})?,
(None, Some(issuer)) => derive_oidc_endpoint(issuer)
.map_err(|_| "the `\"issuer\"` is not a valid url".to_string())?,
(None, None) => {
return Err(
"either `\"openid_configuration_endpoint\"` (or `\"configuration_endpoint\"`) or `\"issuer\"` is required"
.to_string(),
);
},
};
Comment thread
coderabbitai[bot] marked this conversation as resolved.

fn de_oidc_endpoint_url<'de, D>(deserializer: D) -> Result<Url, D::Error>
where
D: Deserializer<'de>,
{
let url_str = String::deserialize(deserializer)?;
Url::parse(&url_str).map_err(|_| {
de::Error::custom("the `\"openid_configuration_endpoint\"` or `\"configuration_endpoint\"` is not a valid url")
})
Ok(Self {
name: raw.name,
description: raw.description,
oidc_endpoint,
token_metadata: raw.token_metadata,
})
}
}

impl From<LegacyTrustedIssuer> for super::TrustedIssuer {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -345,3 +345,99 @@ fn test_invalid_trusted_issuers_format() {
"Error should mention invalid URL, got: {err}"
);
}

/// Builds a legacy policy store carrying a single trusted issuer.
fn policy_store_with_trusted_issuer(issuer: &serde_json::Value) -> String {
let schema = base64::prelude::BASE64_STANDARD.encode("{}");
json!({
"cedar_version": "v4.0.0",
"policy_stores": {
"test": {
"name": "test",
"schema": schema,
"policies": {},
"trusted_issuers": {
"test_issuer": issuer
}
}
}
})
.to_string()
}

/// Reads back the single trusted issuer's `OpenID` configuration endpoint.
fn parsed_oidc_endpoint(json: &str) -> String {
let store = serde_json::from_str::<LegacyAgamaPolicyStore>(json).expect("should parse");
store
.policy_stores
.get("test")
.expect("policy store")
.trusted_issuers
.as_ref()
.expect("trusted issuers")
.get("test_issuer")
.expect("trusted issuer")
.oidc_endpoint
.to_string()
}

#[test]
fn test_trusted_issuer_derives_endpoint_from_issuer() {
let json = policy_store_with_trusted_issuer(&json!({
"name": "test",
"description": "test",
"issuer": "https://accounts.test.com"
}));

assert_eq!(
parsed_oidc_endpoint(&json),
"https://accounts.test.com/.well-known/openid-configuration"
);
}

#[test]
fn test_trusted_issuer_endpoint_takes_precedence_over_issuer() {
let json = policy_store_with_trusted_issuer(&json!({
"name": "test",
"description": "test",
"issuer": "https://accounts.test.com",
"openid_configuration_endpoint": "https://accounts.test.com/custom/openid-configuration"
}));

assert_eq!(
parsed_oidc_endpoint(&json),
"https://accounts.test.com/custom/openid-configuration"
);
}

#[test]
fn test_trusted_issuer_without_endpoint_or_issuer_errors() {
let json = policy_store_with_trusted_issuer(&json!({
"name": "test",
"description": "test"
}));

let result = serde_json::from_str::<LegacyAgamaPolicyStore>(&json);
let err = result.expect_err("Expected error when neither field is present");
assert!(
err.to_string().contains("issuer"),
"Error should name the missing fields, got: {err}"
);
}

#[test]
fn test_trusted_issuer_invalid_issuer_url() {
let json = policy_store_with_trusted_issuer(&json!({
"name": "test",
"description": "test",
"issuer": "invalid_url"
}));

let result = serde_json::from_str::<LegacyAgamaPolicyStore>(&json);
let err = result.expect_err("Expected error for invalid issuer URL");
assert!(
err.to_string()
.contains("the `\"issuer\"` is not a valid url"),
"Error should mention the invalid issuer, got: {err}"
);
}
Loading