diff --git a/docs/cedarling/reference/cedarling-policy-store.md b/docs/cedarling/reference/cedarling-policy-store.md index c88fbe89c3c..4e39dc6538e 100644 --- a/docs/cedarling/reference/cedarling-policy-store.md +++ b/docs/cedarling/reference/cedarling-policy-store.md @@ -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 `/.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`. As an OpenID Connect issuer identifier it must use the `https` scheme and must not carry a query or fragment component; anything else is rejected, since the appended discovery path would otherwise land in the query or fragment instead of the path. 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. @@ -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 `/.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). diff --git a/jans-cedarling/cedarling/src/common/policy_store.rs b/jans-cedarling/cedarling/src/common/policy_store.rs index 5c20ff7b2da..9a19fc93f38 100644 --- a/jans-cedarling/cedarling/src/common/policy_store.rs +++ b/jans-cedarling/cedarling/src/common/policy_store.rs @@ -202,6 +202,50 @@ impl TrustedIssuer { } } +/// The well-known path an `OpenID` Connect provider serves its discovery document from. +const OIDC_DISCOVERY_PATH: &str = ".well-known/openid-configuration"; + +/// Why an `issuer` cannot be used as an `OpenID` Connect issuer identifier. +#[derive(Debug, thiserror::Error)] +pub(crate) enum IssuerUrlError { + #[error("not a valid url: {0}")] + Parse(#[from] url::ParseError), + #[error("must use the https scheme, got `{0}`")] + NotHttps(String), + #[error("must not carry a query component")] + HasQuery, + #[error("must not carry a fragment component")] + HasFragment, +} + +/// 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`. +/// +/// The issuer is validated first. A query or fragment would swallow the appended path instead of +/// extending it, silently yielding a discovery url that points somewhere else entirely, so such +/// issuers are rejected rather than derived from. +pub(crate) fn derive_oidc_endpoint(issuer: &str) -> Result { + let parsed = Url::parse(issuer)?; + + if parsed.scheme() != "https" { + return Err(IssuerUrlError::NotHttps(parsed.scheme().to_string())); + } + if parsed.query().is_some() { + return Err(IssuerUrlError::HasQuery); + } + if parsed.fragment().is_some() { + return Err(IssuerUrlError::HasFragment); + } + + Ok(Url::parse(&format!( + "{}/{OIDC_DISCOVERY_PATH}", + issuer.trim_end_matches('/') + ))?) +} + /// Container for compiled Cedar policies and their descriptions. #[derive(Debug, Clone)] pub(crate) struct PoliciesContainer { diff --git a/jans-cedarling/cedarling/src/common/policy_store/errors.rs b/jans-cedarling/cedarling/src/common/policy_store/errors.rs index 374a0a022a3..580ba11e397 100644 --- a/jans-cedarling/cedarling/src/common/policy_store/errors.rs +++ b/jans-cedarling/cedarling/src/common/policy_store/errors.rs @@ -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 }, diff --git a/jans-cedarling/cedarling/src/common/policy_store/issuer_parser.rs b/jans-cedarling/cedarling/src/common/policy_store/issuer_parser.rs index feb72018fb1..6b4041f2e3f 100644 --- a/jans-cedarling/cedarling/src/common/policy_store/issuer_parser.rs +++ b/jans-cedarling/cedarling/src/common/policy_store/issuer_parser.rs @@ -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; @@ -80,19 +80,25 @@ impl IssuerParser { .and_then(|v| v.as_str()) .unwrap_or(""); - let oidc_endpoint_str = obj + // Kept as the raw value: a present-but-non-string endpoint is a configuration error, and + // collapsing it to `None` here would silently derive from `issuer` instead of reporting it. + 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(), - }, - })?; + .or_else(|| obj.get("configuration_endpoint")); // key that was used in RFC + + let oidc_endpoint = if let Some(endpoint_value) = explicit_endpoint { + let oidc_endpoint_str = + endpoint_value + .as_str() + .ok_or_else(|| PolicyStoreError::TrustedIssuerError { + file: filename.to_string(), + err: TrustedIssuerErrorType::InvalidOidcEndpoint { + issuer_id: issuer_id.clone(), + url: endpoint_value.to_string(), + reason: "must be a string".to_string(), + }, + })?; - let oidc_endpoint = Url::parse(oidc_endpoint_str).map_err(|e| PolicyStoreError::TrustedIssuerError { file: filename.to_string(), err: TrustedIssuerErrorType::InvalidOidcEndpoint { @@ -100,8 +106,30 @@ impl IssuerParser { 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(), + }, + })? + }; + // 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)? @@ -376,6 +404,201 @@ 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 parsed = IssuerParser::parse_issuer(content, "issuer3.json") + .expect("Should parse an issuer given only its base url"); + + assert_eq!( + parsed[0].issuer.oidc_endpoint.as_str(), + "https://accounts.test.com/.well-known/openid-configuration", + "Should derive the discovery endpoint from the issuer base url" + ); + } + + #[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 parsed = IssuerParser::parse_issuer(content, "issuer4.json") + .expect("Should parse an issuer whose base url carries a path"); + + assert_eq!( + parsed[0].issuer.oidc_endpoint.as_str(), + "https://accounts.test.com/realms/jans/.well-known/openid-configuration", + "Should keep the issuer path and drop its trailing slash" + ); + } + + #[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 parsed = IssuerParser::parse_issuer(content, "issuer5.json") + .expect("Should parse when both the endpoint and the issuer are given"); + + assert_eq!( + parsed[0].issuer.oidc_endpoint.as_str(), + "https://accounts.test.com/custom/openid-configuration", + "Should prefer the explicitly configured endpoint over the derived one" + ); + } + + #[test] + fn test_parse_issuer_rejects_non_https_issuer() { + let content = r#"{ + "name": "Test Issuer", + "description": "Issuer served over http", + "issuer": "http://accounts.test.com" + }"#; + + let err = IssuerParser::parse_issuer(content, "http-issuer.json") + .expect_err("An OIDC issuer identifier must use https"); + + assert!( + matches!( + &err, + PolicyStoreError::TrustedIssuerError { + err: TrustedIssuerErrorType::InvalidIssuerUrl { url, .. }, + .. + } if url == "http://accounts.test.com" + ), + "Expected InvalidIssuerUrl for `http://accounts.test.com`, got: {err:?}" + ); + } + + #[test] + fn test_parse_issuer_rejects_issuer_with_query() { + let content = r#"{ + "name": "Test Issuer", + "description": "Issuer carrying a query", + "issuer": "https://accounts.test.com/realms?tenant=a" + }"#; + + let err = IssuerParser::parse_issuer(content, "query-issuer.json") + .expect_err("An issuer carrying a query cannot identify an OIDC provider"); + + assert!( + matches!( + &err, + PolicyStoreError::TrustedIssuerError { + err: TrustedIssuerErrorType::InvalidIssuerUrl { url, .. }, + .. + } if url == "https://accounts.test.com/realms?tenant=a" + ), + "Expected InvalidIssuerUrl for `https://accounts.test.com/realms?tenant=a`, got: {err:?}" + ); + } + + #[test] + fn test_parse_issuer_rejects_issuer_with_fragment() { + let content = r#"{ + "name": "Test Issuer", + "description": "Issuer carrying a fragment", + "issuer": "https://accounts.test.com/realms#frag" + }"#; + + let err = IssuerParser::parse_issuer(content, "fragment-issuer.json") + .expect_err("An issuer carrying a fragment cannot identify an OIDC provider"); + + assert!( + matches!( + &err, + PolicyStoreError::TrustedIssuerError { + err: TrustedIssuerErrorType::InvalidIssuerUrl { url, .. }, + .. + } if url == "https://accounts.test.com/realms#frag" + ), + "Expected InvalidIssuerUrl for `https://accounts.test.com/realms#frag`, got: {err:?}" + ); + } + + #[test] + fn test_parse_issuer_rejects_null_endpoint() { + let content = r#"{ + "name": "Test Issuer", + "description": "Endpoint is present but null", + "issuer": "https://accounts.test.com", + "openid_configuration_endpoint": null + }"#; + + let err = IssuerParser::parse_issuer(content, "null-endpoint.json") + .expect_err("A present but null endpoint must not fall back to the issuer"); + + assert!( + matches!( + &err, + PolicyStoreError::TrustedIssuerError { + err: TrustedIssuerErrorType::InvalidOidcEndpoint { reason, .. }, + .. + } if reason == "must be a string" + ), + "Expected InvalidOidcEndpoint for a null endpoint, got: {err:?}" + ); + } + + #[test] + fn test_parse_issuer_rejects_numeric_endpoint() { + let content = r#"{ + "name": "Test Issuer", + "description": "Endpoint is present but numeric", + "issuer": "https://accounts.test.com", + "openid_configuration_endpoint": 42 + }"#; + + let err = IssuerParser::parse_issuer(content, "numeric-endpoint.json") + .expect_err("A present but numeric endpoint must not fall back to the issuer"); + + assert!( + matches!( + &err, + PolicyStoreError::TrustedIssuerError { + err: TrustedIssuerErrorType::InvalidOidcEndpoint { reason, .. }, + .. + } if reason == "must be a string" + ), + "Expected InvalidOidcEndpoint for a numeric endpoint, got: {err:?}" + ); + } + + #[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#"{ diff --git a/jans-cedarling/cedarling/src/common/policy_store/legacy_store/mod.rs b/jans-cedarling/cedarling/src/common/policy_store/legacy_store/mod.rs index e603b621f0c..361b35b2b28 100644 --- a/jans-cedarling/cedarling/src/common/policy_store/legacy_store/mod.rs +++ b/jans-cedarling/cedarling/src/common/policy_store/legacy_store/mod.rs @@ -23,6 +23,7 @@ use serde::de::{self, Error}; use serde::{Deserialize, Deserializer}; use url::Url; +use super::{IssuerUrlError, derive_oidc_endpoint}; use crate::common::PartitionResult; use crate::common::cedar_schema::cedar_json::CedarSchemaJson; use crate::common::default_entities::{ @@ -82,27 +83,116 @@ impl From 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, +} + +/// 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: ConfiguredEndpoint, #[serde(default)] - pub(crate) token_metadata: HashMap, + issuer: Option, + #[serde(default)] + token_metadata: HashMap, } -fn de_oidc_endpoint_url<'de, D>(deserializer: D) -> Result -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") - }) +/// How a legacy entry named its `OpenID` configuration endpoint. +/// +/// An omitted endpoint is what allows falling back to deriving one from `issuer`; a present but +/// null endpoint is a configuration error, so the two must stay distinguishable. +#[derive(Debug, Default)] +enum ConfiguredEndpoint { + #[default] + Absent, + Null, + Url(String), +} + +impl<'de> Deserialize<'de> for ConfiguredEndpoint { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + match Option::::deserialize(deserializer)? { + Some(url) => Ok(Self::Url(url)), + None => Ok(Self::Null), + } + } +} + +/// Why a legacy trusted issuer entry cannot be converted. +#[derive(Debug, thiserror::Error)] +enum LegacyTrustedIssuerError { + #[error("the `{field}` ({url}) is not a valid url: {source}")] + InvalidEndpoint { + field: &'static str, + url: String, + #[source] + source: url::ParseError, + }, + #[error("the `\"issuer\"` ({url}) is not usable: {source}")] + InvalidIssuer { + url: String, + #[source] + source: IssuerUrlError, + }, + #[error("the `\"openid_configuration_endpoint\"` must be a string, not null")] + NullEndpoint, + #[error("either `\"openid_configuration_endpoint\"` or `\"issuer\"` is required")] + NoEndpointOrIssuer, +} + +impl TryFrom for LegacyTrustedIssuer { + type Error = LegacyTrustedIssuerError; + + fn try_from(raw: LegacyTrustedIssuerRaw) -> Result { + let oidc_endpoint = match (&raw.oidc_endpoint, 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. + (ConfiguredEndpoint::Url(url), _) => { + Url::parse(url).map_err(|source| LegacyTrustedIssuerError::InvalidEndpoint { + field: "\"openid_configuration_endpoint\"", + url: url.clone(), + source, + })? + }, + (ConfiguredEndpoint::Null, _) => return Err(LegacyTrustedIssuerError::NullEndpoint), + (ConfiguredEndpoint::Absent, Some(issuer)) => { + derive_oidc_endpoint(issuer).map_err(|source| { + LegacyTrustedIssuerError::InvalidIssuer { + url: issuer.to_string(), + source, + } + })? + }, + (ConfiguredEndpoint::Absent, None) => { + return Err(LegacyTrustedIssuerError::NoEndpointOrIssuer); + }, + }; + + Ok(Self { + name: raw.name, + description: raw.description, + oidc_endpoint, + token_metadata: raw.token_metadata, + }) + } } impl From for super::TrustedIssuer { diff --git a/jans-cedarling/cedarling/src/common/policy_store/legacy_store/test.rs b/jans-cedarling/cedarling/src/common/policy_store/legacy_store/test.rs index 8ec84ca486f..f05a60e0a21 100644 --- a/jans-cedarling/cedarling/src/common/policy_store/legacy_store/test.rs +++ b/jans-cedarling/cedarling/src/common/policy_store/legacy_store/test.rs @@ -340,8 +340,154 @@ fn test_invalid_trusted_issuers_format() { let result = serde_json::from_str::(&json.to_string()); let err = result.expect_err("Expected error for invalid openid_configuration_endpoint URL"); assert!( - err.to_string() - .contains("the `\"openid_configuration_endpoint\"` or `\"configuration_endpoint\"` is not a valid url"), - "Error should mention invalid URL, got: {err}" + err.to_string().contains("openid_configuration_endpoint") + && err.to_string().contains("invalid_url"), + "Error should name the field and the rejected 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::(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", + "Should derive the discovery endpoint from the issuer base url" + ); +} + +#[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", + "Should prefer the explicitly configured endpoint over the derived one" + ); +} + +#[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::(&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_null_endpoint_does_not_fall_back_to_issuer() { + let json = policy_store_with_trusted_issuer(&json!({ + "name": "test", + "description": "test", + "issuer": "https://accounts.test.com", + "openid_configuration_endpoint": null + })); + + let result = serde_json::from_str::(&json); + let err = result.expect_err("A present but null endpoint must not derive from the issuer"); + assert!( + err.to_string().contains("must be a string"), + "Error should say the endpoint must be a string, got: {err}" + ); +} + +#[test] +fn test_trusted_issuer_rejects_non_https_issuer() { + let json = policy_store_with_trusted_issuer(&json!({ + "name": "test", + "description": "test", + "issuer": "http://accounts.test.com" + })); + + let result = serde_json::from_str::(&json); + let err = result.expect_err("An OIDC issuer identifier must use https"); + assert!( + err.to_string().contains("https"), + "Error should say the issuer must use https, got: {err}" + ); +} + +#[test] +fn test_trusted_issuer_rejects_issuer_with_query() { + let json = policy_store_with_trusted_issuer(&json!({ + "name": "test", + "description": "test", + "issuer": "https://accounts.test.com/realms?tenant=a" + })); + + let result = serde_json::from_str::(&json); + let err = result.expect_err("An issuer carrying a query cannot identify an OIDC provider"); + assert!( + err.to_string().contains("query"), + "Error should say the issuer must not carry a query, 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::(&json); + let err = result.expect_err("Expected error for invalid issuer URL"); + assert!( + err.to_string().contains("invalid_url"), + "Error should name the rejected issuer url, got: {err}" ); } diff --git a/jans-cedarling/schema/policy_store_schema.json b/jans-cedarling/schema/policy_store_schema.json index c0263664dfd..637126d3de4 100644 --- a/jans-cedarling/schema/policy_store_schema.json +++ b/jans-cedarling/schema/policy_store_schema.json @@ -202,8 +202,14 @@ "type": "string", "default": "" }, + "issuer": { + "description": "The base url of the issuer, as an OpenID Connect issuer identifier: it must use the https scheme and must not carry a query or fragment component. When 'openid_configuration_endpoint' is absent, the discovery document is looked up at '/.well-known/openid-configuration'.", + "type": "string", + "format": "uri", + "pattern": "^https://[^?#]*$" + }, "openid_configuration_endpoint": { - "description": "The URL to the trusted issuer's OpenID Connect discovery document, which contains metadata about the issuer (e.g., authorization endpoint, token endpoint).", + "description": "The URL to the trusted issuer's OpenID Connect discovery document, which contains metadata about the issuer (e.g., authorization endpoint, token endpoint). Takes precedence over 'issuer'.", "type": "string", "format": "uri" }, @@ -218,7 +224,11 @@ "additionalProperties": false } }, - "required": ["name", "openid_configuration_endpoint"], + "required": ["name"], + "anyOf": [ + { "required": ["openid_configuration_endpoint"] }, + { "required": ["issuer"] } + ], "additionalProperties": true }, "TokenMetadata": {