Multi-Issuer Authorization Guide#
This guide provides a comprehensive overview of Cedarling's multi-issuer authorization feature, including concepts, implementation patterns, and real-world use cases.
Overview#
Multi-issuer authorization (authorize_multi_issuer) enables applications to make authorization decisions based on multiple JWT tokens from different identity providers in a single request. Unlike traditional authorization that creates User and Workload principals, multi-issuer authorization evaluates policies based purely on token entities themselves.
A batch variant, authorize_multi_issuer_batch, validates the token set once and evaluates N {resource, action, context} items against that shared snapshot. Same token contract as documented on this page (validation, entity creation, context.tokens naming, failure handling); the batching mechanics — request/response shape, batch_id correlation, and the batch-level vs per-item failure split — are covered in Batch Authorization.
Key Benefits#
- Federation Support: Native support for tokens from multiple identity providers
- Capability-Based Authorization: Make decisions based on capabilities asserted by different issuers
- Zero Trust Architecture: Each token represents verification from a different trust boundary
- Flexible Token Types: Support for custom token types beyond standard OAuth/OIDC tokens
- API Gateway Ready: Ideal for API gateways validating tokens from various upstream services
How It Works#
1. Request Structure#
A multi-issuer authorization request consists of:
{
"tokens": [
{
"mapping": "Jans::Access_Token",
"payload": "eyJhbGciOiJIUzI1NiIs..."
},
{
"mapping": "Jans::Id_Token",
"payload": "eyJhbGciOiJFZERTQSIs..."
},
{
"mapping": "Acme::DolphinToken",
"payload": "ey1b6cfMef21084633a7..."
}
],
"action": "Jans::Action::\"Read\"",
"resource": {
"cedar_entity_mapping": {
"entity_type": "Jans::Document",
"id": "doc-123"
},
"owner": "alice@example.com",
"classification": "confidential"
},
"context": {
"ip_address": "54.9.21.201",
"time": 1730000000
}
}
2. Token Processing Pipeline#
┌─────────────────────────────────────────────────────────┐
│ 1. Token Input │
│ Array of tokens with explicit type mappings │
└────────────────────┬────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────┐
│ 2. Token Validation │
│ - Signature verification │
│ - Time-based validation (exp, nbf) │
│ - Status validation (revocation check) │
│ - Trusted issuer verification │
└────────────────────┬────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────┐
│ 3. Entity Creation │
│ - Create Cedar entity for each valid token │
│ - Store token metadata (type, jti, issuer, exp) │
│ - Store JWT claims as entity tags (Set<String>) │
└────────────────────┬────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────┐
│ 4. Token Collection Assembly │
│ - Organize tokens with predictable naming │
│ - Pattern: {issuer_name}_{token_type} │
│ - Example: acme_access_token, google_id_token │
└────────────────────┬────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────┐
│ 5. Policy Evaluation │
│ - Evaluate Cedar policies without principal │
│ - Policies reference context.tokens.{name} │
│ - Return authorization decision │
└─────────────────────────────────────────────────────────┘
3. Token Entity Structure#
Each validated token becomes a Cedar entity:
entity Token = {
"token_type"?: String, // e.g., "Jans::Access_Token"
"jti"?: String, // Token ID
"iss"?: Jans::TrustedIssuer, // JWT iss claim
"exp"?: Long, // Expiration timestamp
"validated_at"?: Long // Validation timestamp
} tags Set<String>;
All JWT claims are stored as tags and accessed using Cedar's tag operations:
context.tokens.acme_access_token.hasTag("scope")
context.tokens.acme_access_token.getTag("scope").contains("read:profile")
4. Token Collection Naming#
Tokens are organized in the context using a deterministic naming algorithm:
Pattern: {issuer_name}_{token_type}
Issuer Name Resolution:
- Look up the token's
mappingin the policy store'strusted-issuers.json - Use that entry's
issuer, lowercased
A token whose mapping has no entry is dropped, so every key comes from the
configuration. Keys are computed once when the store loads and must be unique.
Token Type Resolution:
- Extract from
mappingfield (e.g., "Jans::Access_Token") - Split by namespace separator ("::"), and use the last segment.
- Convert to lowercase, preserve underscores
Examples:
| JWT Issuer | Entry issuer |
Token Mapping | Result |
|---|---|---|---|
https://idp.acme.com/auth |
"Acme" |
Jans::Access_Token |
acme_access_token |
https://accounts.google.com |
"Google" |
Jans::Id_Token |
google_id_token |
https://idp.dolphin.sea/auth |
"Dolphin" |
Acme::DolphinToken |
dolphin_dolphintoken |
Use Cases#
Use Case 1: Federation Scenario#
Scenario: A collaborative platform accepts tokens from multiple corporate identity providers.
Requirements:
- Users can authenticate with their corporate IDP
- Authorization requires valid token from user's organization
- Different organizations have different permission structures
Implementation:
# User presents tokens from their corporate IDP and the platform IDP
tokens = [
TokenInput(
mapping="Jans::Access_Token",
payload="<acme_corp_token>" # From Acme Corp IDP
),
TokenInput(
mapping="Platform::Access_Token",
payload="<platform_token>" # From platform IDP
)
]
request = AuthorizeMultiIssuerRequest(
tokens=tokens,
action='Platform::Action::"ShareDocument"',
resource=document,
context={"ip_address": "192.168.1.100"}
)
result = cedarling.authorize_multi_issuer(request)
Policy:
// Allow sharing if user has valid tokens from both IDPs
permit(
principal,
action == Platform::Action::"ShareDocument",
resource in Platform::Document
) when {
// Verify corporate IDP token with employee status
context has tokens.acme_corp_access_token &&
context.tokens.acme_corp_access_token.hasTag("employee_status") &&
context.tokens.acme_corp_access_token.getTag("employee_status").contains("active") &&
// Verify platform token with sharing scope
context has tokens.platform_access_token &&
context.tokens.platform_access_token.hasTag("scope") &&
context.tokens.platform_access_token.getTag("scope").contains("share:documents")
};
Use Case 2: API Gateway with Multiple Upstream Services#
Scenario: An API gateway needs to validate tokens from various upstream microservices.
Requirements:
- Each microservice issues its own JWT tokens
- Gateway validates all tokens before forwarding requests
- Authorization based on combination of service capabilities
Implementation:
// Gateway receives tokens from multiple services
let tokens = [
{
mapping: "AuthService::Access_Token",
payload: authServiceToken,
},
{
mapping: "PaymentService::Access_Token",
payload: paymentServiceToken,
},
{
mapping: "UserService::Access_Token",
payload: userServiceToken,
},
];
let request = {
tokens: tokens,
action: 'Gateway::Action::"ProcessPayment"',
resource: {
cedar_entity_mapping: {
entity_type: "Gateway::Transaction",
id: "txn-12345",
},
amount: 1000.0,
currency: "USD",
},
context: {
ip_address: request.ip,
user_agent: request.headers["user-agent"],
},
};
// WASM binding: the request is passed as a JSON string
let result = await cedarling.authorizeMultiIssuer(JSON.stringify(request));
Policy:
// Require tokens from all three services for payment processing
permit(
principal,
action == Gateway::Action::"ProcessPayment",
resource in Gateway::Transaction
) when {
// Auth service token with authenticated user
context has tokens.auth_service_access_token &&
context.tokens.auth_service_access_token.hasTag("authenticated") &&
// Payment service token with sufficient balance
context has tokens.payment_service_access_token &&
context.tokens.payment_service_access_token.hasTag("balance_verified") &&
// User service token with kyc_verified status
context has tokens.user_service_access_token &&
context.tokens.user_service_access_token.hasTag("kyc_verified") &&
context.tokens.user_service_access_token.getTag("kyc_verified").contains("true")
};
Use Case 3: Multi-Organization Voting System#
Scenario: A trade association requires tokens from both the association and member organizations for voting.
Requirements:
- User must have valid membership token from trade association
- User must have valid employee token from their organization
- Organization must be a corporate member
- User must be designated as voting representative
Implementation:
tokens := []cedarling_go.TokenInput{
{
Mapping: "TradeAssociation::MemberToken",
Payload: memberToken,
},
{
Mapping: "Jans::Access_Token",
Payload: employeeToken,
},
}
request := cedarling_go.AuthorizeMultiIssuerRequest{
Tokens: tokens,
Action: `TradeAssociation::Action::"Vote"`,
Resource: cedarling_go.EntityData{
CedarMapping: cedarling_go.CedarMapping{
EntityType: "TradeAssociation::Election",
ID: "election-2025",
},
Payload: map[string]any{
"election_type": "board",
"year": 2025,
},
},
}
result, err := instance.AuthorizeMultiIssuer(request)
Policy:
permit(
principal,
action == TradeAssociation::Action::"Vote",
resource in TradeAssociation::Election
) when {
// Require corporate membership token
context has tokens.trade_association_member_token &&
context.tokens.trade_association_member_token.hasTag("member_status") &&
context.tokens.trade_association_member_token.getTag("member_status").contains("Corporate Member") &&
// Require employee token with voting representative designation
context has tokens.company_access_token &&
context.tokens.company_access_token.hasTag("role") &&
context.tokens.company_access_token.getTag("role").contains("voting_representative")
};
Use Case 4: Healthcare HIPAA Compliance#
Scenario: Healthcare system requires multiple consent tokens for accessing medical records.
Requirements:
- Patient consent token required
- Provider credentials token required
- Facility authorization token required
- Purpose of use must match all tokens
Implementation:
tokens = [
TokenInput(
mapping="Healthcare::PatientConsent",
payload=patient_consent_token
),
TokenInput(
mapping="Healthcare::ProviderCredentials",
payload=provider_credentials_token
),
TokenInput(
mapping="Healthcare::FacilityAuth",
payload=facility_token
)
]
request = AuthorizeMultiIssuerRequest(
tokens=tokens,
action='Healthcare::Action::"AccessMedicalRecord"',
resource=medical_record,
context={
"purpose_of_use": "TREATMENT",
"emergency": False
}
)
Policy:
permit(
principal,
action == Healthcare::Action::"AccessMedicalRecord",
resource in Healthcare::MedicalRecord
) when {
// Patient consent token
context has tokens.patient_consent &&
context.tokens.patient_consent.hasTag("consent_status") &&
context.tokens.patient_consent.getTag("consent_status").contains("granted") &&
context.tokens.patient_consent.hasTag("expiry") &&
context.tokens.patient_consent.getTag("expiry").contains("2025-12-31") &&
// Provider credentials token
context has tokens.provider_credentials &&
context.tokens.provider_credentials.hasTag("license_status") &&
context.tokens.provider_credentials.getTag("license_status").contains("active") &&
context.tokens.provider_credentials.hasTag("specialty") &&
// Facility authorization token
context has tokens.facility_auth &&
context.tokens.facility_auth.hasTag("facility_type") &&
context.tokens.facility_auth.getTag("facility_type").contains("hospital") &&
// Purpose of use alignment
context has purpose_of_use &&
context.purpose_of_use == "TREATMENT"
};
Use Case 5: Zero Trust Network with Custom Token Types#
Scenario: A zero-trust architecture uses custom tokens for device attestation, network verification, and user authentication.
Requirements:
- Device attestation token from hardware TPM
- Network security token from network controller
- User authentication token from IDP
- All three required for accessing sensitive resources
Implementation:
let tokens = [
{
mapping: "Security::DeviceAttestation",
payload: deviceAttestationToken, // From device TPM
},
{
mapping: "Security::NetworkToken",
payload: networkToken, // From network controller
},
{
mapping: "Jans::Access_Token",
payload: userAccessToken, // From IDP
},
];
// WASM binding: the request is passed as a JSON string
let result = await cedarling.authorizeMultiIssuer(JSON.stringify({
tokens: tokens,
action: 'Security::Action::"AccessClassified"',
resource: {
cedar_entity_mapping: {
entity_type: "Security::Document",
id: "classified-123",
},
classification: "SECRET",
compartment: "SPECIAL_ACCESS",
},
context: {
location: "secure_facility",
time: Date.now(),
},
}));
Policy:
permit(
principal,
action == Security::Action::"AccessClassified",
resource in Security::Document
) when {
// Device attestation with hardware-backed key
context has tokens.device_attestation &&
context.tokens.device_attestation.hasTag("tpm_verified") &&
context.tokens.device_attestation.getTag("tpm_verified").contains("true") &&
context.tokens.device_attestation.hasTag("encryption_level") &&
context.tokens.device_attestation.getTag("encryption_level").contains("FIPS-140-2") &&
// Network token from secure network
context has tokens.network_token &&
context.tokens.network_token.hasTag("network_type") &&
context.tokens.network_token.getTag("network_type").contains("CLASSIFIED") &&
context.tokens.network_token.hasTag("segment") &&
context.tokens.network_token.getTag("segment").contains("HIGH_SIDE") &&
// User access token with clearance
context has tokens.user_access_token &&
context.tokens.user_access_token.hasTag("clearance_level") &&
context.tokens.user_access_token.getTag("clearance_level").contains("SECRET") &&
// Location verification
context has location &&
context.location == "secure_facility"
};
Custom (Non-JWT) Token Processing#
Multi-issuer authorization is not limited to JWTs. A custom token processor lets Rust consumers authorize on non-JWT credentials like opaque tokens, API keys, vendor-specific formats, or tokens whose validation uses cryptography Cedarling does not implement while reusing the same entity builder, context.tokens.* machinery, and policies as JWTs.
Trust model: the processor's output is authoritative. Cedarling performs no signature or issuer verification on a custom token, validating the payload is entirely the processor's responsibility. Treat a registered processor as fully trusted code. The one exception is expiration: if the processor reports one (via
expirationor anexpclaim), Cedarling rejects the token once that time has passed.Availability: the processor is a Rust trait object registered on a live instance, so this feature is Rust-native only (native and the
blockingclient). It is not exposed through any of the bindings.
How routing works#
Routing is decided by the policy store alone: a token is sent to the custom path instead of the JWT pipeline whenever its mapping equals a token type declared by some custom issuer. Registering a CustomTokenProcessor does not change which path a token takes only whether that path can succeed.
So clearing the processor does not fall back to the JWT pipeline. If a mapping matches a custom issuer but no processor is registered, the token is dropped with no_processor_registered. Each entity type has exactly one entry in the policy store, so a custom type can never shadow a JWT type.
1. Configure the custom issuer#
Declare each custom token type as a custom entry in the policy store's
trusted-issuers.json, keyed
by its Cedar entity type:
{
"Custom::ApiKey": {
"type": "custom",
"issuer": "CustomKeys",
"required_claims": ["sub"]
},
"Custom::SessionKey": {
"type": "custom",
"issuer": "CustomKeys"
}
}
| Field | Type | Description |
|---|---|---|
| key | string (required) | Cedar entity type. A request mapping equal to it is routed to the custom path. |
type |
"custom" (required) |
Selects the custom path; JWT-only fields such as configuration_endpoints are rejected. |
issuer |
string (required) | Issuer identity, a single Cedar identifier. Lowercased, it is the context.tokens key prefix and the token's iss attribute. One issuer may declare several token types, but cannot also declare jwt entries. |
required_claims |
string[] (default []) |
Claims that must be present in the processor output; a missing claim drops the token. |
Each token type lands under its own context.tokens.{issuer}_{token_type} key, so the example above yields customkeys_apikey and customkeys_sessionkey.
Each entity type has exactly one entry, JWT or custom, and every context.tokens key must be unique; a conflict fails the policy store load. Legacy single-file YAML test stores declare the same configuration under a top-level custom_issuers map, see the legacy format.
The matching schema types customkeys_apikey into context.tokens as Custom::ApiKey (all attributes optional, tags Set<String>), following the same rules as Cedar Schema for Multi-Issuer Tokens.
2. Implement the processor#
Implement CustomTokenProcessor::process, which turns a raw payload into ProcessedTokenClaims. One processor handles all custom mappings and dispatches internally on mapping.
use cedarling::{CustomTokenError, CustomTokenProcessor, ProcessedTokenClaims};
use async_trait::async_trait;
use std::collections::HashMap;
struct ApiKeyProcessor;
#[async_trait]
impl CustomTokenProcessor for ApiKeyProcessor {
async fn process(
&self,
mapping: &str,
payload: &str,
) -> Result<ProcessedTokenClaims, CustomTokenError> {
// Validate the opaque payload however you like (DB lookup, HMAC, vault, ...).
if payload != "secret-admin-key" {
return Err(CustomTokenError::Processing(format!(
"unknown API key for mapping '{mapping}'"
)));
}
let mut claims = HashMap::new();
claims.insert("sub".to_string(), serde_json::json!("api-key-user"));
claims.insert("scope".to_string(), serde_json::json!("admin"));
// Re-validate on every request (revocation-sensitive).
Ok(ProcessedTokenClaims::new(claims, "api-key-1").with_cacheable(false))
}
}
ProcessedTokenClaims fields:
| Field | Type | Description |
|---|---|---|
claims |
HashMap<String, serde_json::Value> |
Claims for the token entity. Stored as tags (Set<String>), exactly like JWT claims. |
token_id |
String |
Entity id of the resulting token entity supplied directly, not read from a claim. |
expiration |
Option<i64> (default None) |
Optional expiration (unix seconds). The token is rejected once it passes, and the value bounds the token-cache TTL. Falls back to an exp claim when None; an explicit value wins over the claim. |
cacheable |
bool (default true) |
Set false for revocation-sensitive tokens so every request re-runs process. |
Cedarling resolves the issuer from mapping, which is declared by exactly one custom issuer.
ProcessedTokenClaims::new(claims, token_id) builds a cacheable result with no expiration; chain .with_cacheable(bool) and .with_expiration(i64) to override either.
3. Register the processor#
Register (or clear) the processor on a live instance. It survives policy-store refreshes.
use std::sync::Arc;
cedarling.set_custom_token_processor(Some(Arc::new(ApiKeyProcessor)));
// Later, to disable custom-token processing:
cedarling.set_custom_token_processor(None);
4. Write the policy#
A custom token becomes a Cedar entity at context.tokens.{issuer}_{token_type}: issuer CustomIssuerName + mapping Custom::ApiKey --> customissuername_apikey. Claims are tags; the token entity's iss attribute is the lowercased issuer name as a plain string (there is no TrustedIssuer entity for a custom issuer).
permit(
principal,
action == Custom::Action::"Read",
resource == Custom::Resource::"Doc"
) when {
context has tokens.customkeys_apikey &&
context.tokens.customkeys_apikey.hasTag("scope") &&
context.tokens.customkeys_apikey.getTag("scope").contains("admin")
};
The matching schema types customkeys_apikey into context.tokens as Custom::ApiKey (all attributes optional, tags Set<String>), following the same rules as Cedar Schema for Multi-Issuer Tokens.
Failure handling and caching#
| Situation | Behavior |
|---|---|
Processor returns Ok |
Claims flow into context.tokens.*. |
Processor returns Err |
Token dropped (custom_processing_failed), authorization continues. |
mapping is custom but no processor registered |
Token dropped (no_processor_registered). |
A required_claims entry is absent from the output |
Token dropped (custom_processing_failed). |
The reported expiration (expiration, else an exp claim) has already passed |
Token dropped (custom_processing_failed). |
process exceeds the configured timeout (> 0) |
Token dropped (custom_processing_timed_out). |
Like a JWT, a custom token is never required: a failure drops only that token,
and the request fails only when no token at all survives. See
Token failures and forbid policies.
Set CEDARLING_CUSTOM_TOKEN_PROCESSOR_TIMEOUT_MILLIS to bound slow processors. 0 (the default) disables the timeout; see Cedarling Properties. Keep cacheable: true (the default) to skip re-running process for an identical payload and use false for revocation-sensitive tokens.
A complete, runnable example can be found in
cedarling/examples/custom_token_processor.rs.
Configuration Guide#
Schema Requirements#
IMPORTANT: Multi-issuer authorization requires specific Cedar schema modifications. Without these changes, authorization will fail with schema validation errors.
Required Schema Changes#
Multi-issuer authorization creates token entities dynamically and places them in the Cedar context. Your schema must support:
1. Token Entity Structure
Token entities must have these required attributes:
// Jans namespace: shared infrastructure types used across namespaces.
namespace Jans {
type Url = {
host: String,
path: String,
protocol: String
};
};
// Acme namespace: token entities for the Acme IDP.
namespace Acme {
entity TrustedIssuer = {
issuer_entity_id: Jans::Url
};
entity Access_token = {
token_type?: String, // Required for multi-issuer
jti?: String, // Required for multi-issuer
iss?: TrustedIssuer, // Required for multi-issuer
exp?: Long, // Required for multi-issuer
validated_at?: Long, // Required for multi-issuer
// Other JWT claims as optional attributes
aud?: String,
iat?: Long,
scope?: Set<String>,
// ...
} tags Set<String>; // Required for dynamic JWT claims
};
2. Context Structure
The Context type must include a tokens field:
type Context = {
network?: String,
// ... other context fields
tokens?: TokensContext, // Required for multi-issuer
};
type TokensContext = {
total_token_count: Long, // Required
// Individual token fields added dynamically
};
3. Making Attributes Optional
All token entity attributes (except the core multi-issuer fields) must be optional (?) to prevent schema validation errors. This is because multi-issuer tokens may not have all the claims that standard authorization tokens have.
Why These Changes Are Needed#
- Dynamic token entities: Multi-issuer authorization creates token entities on-the-fly without User/Workload principals
- Tag-based claims: JWT claims are stored as entity tags (
Set<String>by default) for flexible access - Context structure: Tokens are organized in
context.tokens.{issuer}_{token_type}format - Schema validation: Cedar validates entities against the schema; missing required fields cause errors
Updating Core Schema#
If you're using the default cedarling_core.cedarschema from Agama Lab, it has been updated to support multi-issuer authorization. If you have a custom schema, make sure to apply these changes.
Cedar Schema for Multi-Issuer Actions#
Multi-issuer authorization runs Cedar's partial evaluator with no principal — authorize_multi_issuer does not accept one, and none is constructed internally. Actions used in multi-issuer requests must declare this in their appliesTo, and policies referencing those actions must leave the principal unconstrained.
Declaring the action#
Cedar's schema validator rejects an empty principal list (for action '...', 'principal' is '[]', which is invalid). To declare an action that runs without a principal, declare a placeholder entity type purely to satisfy the schema and reference it in appliesTo:
entity Any;
action "ReadArtifact" appliesTo {
principal: [Any],
resource: [Artifact],
context: Context
};
No instance of Any is ever constructed at runtime. authorize_multi_issuer invokes the action with principal: None and Cedar's partial evaluator runs against the policies. The placeholder entity type exists only so the schema parses.
Writing the policy#
Policies for multi-issuer actions must use the unconstrained permit(principal, ...) head — do not add principal == ... or principal is ..., because no principal exists at evaluation time:
permit(
principal,
action == Action::"ReadArtifact",
resource == Artifact::"doc-1"
) when {
context.tokens.AcmeCorp_access_token.scope.contains("read")
};
If a policy does constrain the principal, Cedar's partial evaluator cannot fully evaluate it and emits a residual. Residual-dependent requests fail closed with Deny in Cedarling; the request's diagnostics list the residual policy ids so they can be located and fixed.
Policy Store Configuration#
Declare each accepted token type in the policy store's
trusted-issuers.json, with
an issuer that gives predictable token naming:
{
"AcmeCorp::Access_Token": {
"type": "jwt",
"issuer": "AcmeCorp",
"configuration_endpoints": {
"configuration": "https://idp.acme.com/.well-known/openid-configuration"
}
},
"Google::Id_Token": {
"type": "jwt",
"issuer": "Google",
"configuration_endpoints": {
"configuration": "https://accounts.google.com/.well-known/openid-configuration"
}
},
"CustomService::ServiceToken": {
"type": "jwt",
"issuer": "CustomService",
"configuration_endpoints": {
"configuration": "https://service.example.com/.well-known/openid-configuration"
}
}
}
These yield context.tokens.acmecorp_access_token, google_id_token and
customservice_servicetoken.
Cedar Schema for Multi-Issuer Tokens#
Core Token Schema Structure#
Each token type (Access_Token, Id_Token, custom tokens) must follow this structure:
// Jans namespace: shared infrastructure types used across namespaces.
namespace Jans {
type Url = {
host: String,
path: String,
protocol: String
};
type email_address = {
domain: String,
uid: String
};
};
// Acme namespace: token entities for the Acme IDP.
namespace Acme {
entity TrustedIssuer = {
issuer_entity_id: Jans::Url
};
// Core token entity structure compatible with multi-issuer authorization
entity Access_token = {
// Required multi-issuer attributes
token_type?: String, // Entity type name (e.g., "Acme::Access_token")
jti?: String, // JWT ID - unique token identifier
iss?: TrustedIssuer, // Issuer entity reference
exp?: Long, // Token expiration timestamp
validated_at?: Long, // Timestamp when token was validated
// Optional JWT claims (make all optional for compatibility)
aud?: String, // Audience
iat?: Long, // Issued at
scope?: Set<String>, // OAuth scopes
client_id?: String, // Client identifier
sub?: String, // Subject
// Add other JWT claims as needed
} tags Set<String>; // Tags store dynamic JWT claims
entity id_token = {
// Required multi-issuer attributes
token_type?: String,
jti?: String,
iss?: TrustedIssuer,
exp?: Long,
validated_at?: Long,
// Optional JWT claims
aud?: Set<String>,
iat?: Long,
sub?: String,
email?: Jans::email_address,
name?: String,
phone_number?: String,
role?: Set<String>,
acr?: String,
amr?: Set<String>,
// Add other JWT claims as needed
} tags Set<String>;
entity Userinfo_token = {
// Required multi-issuer attributes
token_type?: String,
jti?: String,
iss?: TrustedIssuer,
exp?: Long,
validated_at?: Long,
// Optional JWT claims
aud?: String,
iat?: Long,
sub?: String,
email?: Jans::email_address,
name?: String,
birthdate?: String,
phone_number?: String,
role?: Set<String>,
// Add other JWT claims as needed
} tags Set<String>;
};
Custom Token Types#
For custom token types, follow the same pattern:
// Jans namespace: shared infrastructure types used across namespaces.
namespace Jans {
type Url = {
host: String,
path: String,
protocol: String
};
};
// Custom namespace: custom service token entities.
namespace Custom {
entity TrustedIssuer = {
issuer_entity_id: Jans::Url
};
entity ServiceToken = {
// Required multi-issuer attributes
token_type?: String,
jti?: String,
iss?: TrustedIssuer,
exp?: Long,
validated_at?: Long,
// Custom token-specific attributes
service_id?: String,
permissions?: Set<String>,
service_tier?: String,
} tags Set<String>;
};
Complete Context Schema#
Define the Context type to include the tokens field:
type Context = {
// Standard context fields
network?: String,
network_type?: String,
user_agent?: String,
operating_system?: String,
device_health?: Set<String>,
current_time?: Long,
geolocation?: Set<String>,
fraud_indicators?: Set<String>,
// Multi-issuer tokens context (required)
tokens?: TokensContext,
};
type TokensContext = {
total_token_count: Long,
// Individual token fields are added dynamically by Cedarling
// Pattern: {issuer_name}_{token_type} (e.g., acme_access_token)
};
Key Schema Principles#
- Optional Attributes: All token attributes must be optional (
?) to support both standard and multi-issuer authorization - Tags Declaration: All token entities must declare
tags Set<String>for dynamic JWT claim storage - Context Integration: The Context type must include an optional
tokensfield - Consistency: Use the same attribute names across all token types (token_type, jti, issuer, exp, validated_at)
Error Handling#
Token Validation Failures#
# Individual token validation failures are handled gracefully
tokens = [
TokenInput(mapping="Jans::Access_Token", payload="valid_token"),
TokenInput(mapping="Jans::Id_Token", payload="invalid_token"), # Will be ignored
TokenInput(mapping="Acme::CustomToken", payload="valid_custom_token")
]
# Authorization continues with valid tokens
# Invalid tokens are logged but don't block processing
result = cedarling.authorize_multi_issuer(request)
However, if every token is invalid, Cedarling will raise an error. It is important that users always handle errors gracefully.
Token failures and forbid policies#
Every token is optional, JWT and custom alike. A token that fails validation or
processing is dropped with a WARN log and a metric, and authorization
continues with the remaining tokens. Only when no token survives does the request
fail, with TokenValidationFailed. Dropped tokens are reported in
dropped_tokens, but the decision itself is made
without them.
What that means for policies:
- A
permitthat needs a dropped token does not fire. That is safe: the request is denied unless another policy permits it. - A
forbidthat reads a dropped token does not fire either, which can turn a denial into an allow. A condition such ascontext.tokens.acme_access_token.getTag("risk").contains("high")cannot forbid anything when the token is missing.
So a token that must be present has to be required in policy, with a forbid
that fires when it is absent:
forbid (principal, action, resource)
unless { context has tokens.acme_access_token };
This replaces the required flag of version 1 custom issuers, which made a
failing token fail the whole request.
Inspecting dropped tokens#
A dropped token no longer has to be recovered from the logs. Every
MultiIssuerAuthorizeResult carries a dropped_tokens list, and the same list
is written to the decision-log entry for the request. Each entry identifies the
caller's token by its input mapping and its zero-based index in the request
tokens array, plus a claim-free reason. Every binding exposes the same
shape as the JSON form:
{
"mapping": "Invalid::Token",
"index": 1,
"reason": {
"kind": "jwt_validation_failed",
"message": "JWT validation failed"
}
}
| Binding | Access |
|---|---|
| Python | result.dropped_tokens()[i].reason.kind |
| JavaScript (WASM) | result.droppedTokens()[i].reason.kind |
| Kotlin / Java / Swift | result.droppedTokens[i].reason.kind() (typed enum: reason.variant) |
| Go | result.DroppedTokens[i].Reason.Kind |
result = cedarling.authorize_multi_issuer(request)
for dropped in result.dropped_tokens():
print(f"token #{dropped.index} ({dropped.mapping}) dropped: "
f"{dropped.reason.kind} - {dropped.reason.message}")
reason.kind is one of the following stable slugs:
| Reason slug | Meaning |
|---|---|
invalid_input |
The token input was malformed (empty mapping or payload). reason.detail is the stable slug ("empty_mapping" / "empty_payload"). |
jwt_validation_failed |
Signature/claims validation failed, or no trusted issuer declares the token's mapping. |
duplicate_token |
An earlier token with the same mapping was already accepted; this one was not validated (see Non-Deterministic Tokens). |
duplicate_mapping |
No longer emitted: a second token for a mapping is reported as duplicate_token. Kept for compatibility. |
no_processor_registered |
The mapping routes to a custom issuer but no processor is registered. |
custom_processing_failed |
A registered custom processor rejected the token. |
custom_processing_timed_out |
A registered custom processor exceeded CEDARLING_CUSTOM_TOKEN_PROCESSOR_TIMEOUT_MILLIS. |
entity_build_failed |
The token validated but a Cedar entity could not be built from it. |
The reason is deliberately claim-free: it never contains token payloads or claim
values, so it is safe to log and surface to callers. reason.message is
non-empty for every slug (e.g. a token with the same mapping was already accepted),
while reason.detail is set only for invalid_input and is absent (None /
null / nil) otherwise. Order is not
guaranteed; use each entry's index to correlate with the request tokens
array. When no token is dropped
the list is empty and the field is omitted from the serialized result. For a
batch request the list is produced once (tokens are validated once for the whole
batch) and repeats on every item's result.
Non-Deterministic Tokens#
# Two tokens for the same mapping: policies could not tell which one to use
tokens = [
TokenInput(mapping="Jans::Access_Token", payload="token1"), # accepted
TokenInput(mapping="Jans::Access_Token", payload="token2"), # dropped: duplicate_token
]
A mapping names one token type of one issuer, so only one token per mapping is
used. The first one that is accepted wins; later ones for the same mapping are
dropped as duplicate_token and logged, without being validated. A token that
fails validation does not count, so a later token for its mapping is still
checked.
Trusted Issuer Validation#
# A JWT is accepted only from the issuer its mapping names
tokens = [
TokenInput(
mapping="Jans::Access_Token",
payload="token_from_another_issuer" # dropped: its iss is not the entry's iss
)
]
# The token's iss must equal the expected iss of the mapping's entry in
# trusted-issuers.json; otherwise the token is dropped (jwt_validation_failed).
Best Practices#
1. Use Descriptive Issuer Names#
Configure clear, predictable issuer names in your policy store:
{
"issuer": "AcmeCorp", // Good - clear and predictable
"issuer": "Issuer1" // Bad - unclear what this represents
}
The issuer is visible to policies, as the TrustedIssuer namespace and the
context.tokens key prefix, so renaming it later means updating policies.
2. Start Schema-Less for Development#
Begin without Cedar schemas for rapid development:
- All claims stored in tags as
Set<String> - Flexible and forgiving during development
- Add schemas later for production type safety
3. Implement Comprehensive Logging#
Monitor token validation and policy evaluation:
result = cedarling.authorize_multi_issuer(request)
# Retrieve logs for debugging
logs = cedarling.get_logs_by_request_id(result.request_id)
for log in logs:
print(f"Log: {log}")
4. Handle Failed Tokens Gracefully#
Design policies to work with partial token sets:
// Allow if EITHER token is present
permit(
principal,
action == Jans::Action::"Read",
resource in Jans::Document
) when {
(context has tokens.acme_access_token &&
context.tokens.acme_access_token.hasTag("scope") &&
context.tokens.acme_access_token.getTag("scope").contains("read")) ||
(context has tokens.google_access_token &&
context.tokens.google_access_token.hasTag("scope") &&
context.tokens.google_access_token.getTag("scope").contains("read"))
};
5. Test with Multiple Issuer Combinations#
Test policies with various token combinations:
# Test with all tokens
all_tokens_result = cedarling.authorize_multi_issuer(all_tokens_request)
# Test with subset of tokens
partial_tokens_result = cedarling.authorize_multi_issuer(partial_tokens_request)
# Test with invalid tokens mixed in
mixed_tokens_result = cedarling.authorize_multi_issuer(mixed_tokens_request)
See Also#
- Cedarling Authorization
- Batch Authorization
- Cedarling Interfaces
- Policy Store Configuration
- Python Examples
- Go Examples