Skip to content

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:

  1. Look up the token's mapping in the policy store's trusted-issuers.json
  2. 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:

  1. Extract from mapping field (e.g., "Jans::Access_Token")
  2. Split by namespace separator ("::"), and use the last segment.
  3. 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 expiration or an exp claim), 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 blocking client). 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#

  1. Optional Attributes: All token attributes must be optional (?) to support both standard and multi-issuer authorization
  2. Tags Declaration: All token entities must declare tags Set<String> for dynamic JWT claim storage
  3. Context Integration: The Context type must include an optional tokens field
  4. 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 permit that needs a dropped token does not fire. That is safe: the request is denied unless another policy permits it.
  • A forbid that reads a dropped token does not fire either, which can turn a denial into an allow. A condition such as context.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#