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 issuer in trusted issuer metadata
  2. Use the name field from configuration
  3. If no name field, extract hostname from JWT iss claim
  4. Convert to lowercase, replace special characters with underscores

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 Trusted Issuer Name 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.authorize_multi_issuer(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.authorize_multi_issuer(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 fails with NoProcessorRegistered (the whole request fails) for a required issuer, otherwise the token is skipped. A custom mapping therefore cannot equal a JWT trusted issuer's token type; that collision is rejected at instance startup.

1. Configure the custom issuer#

Add a custom_issuers map to the policy store, keyed by issuer name (mirrors trusted_issuers). For the directory format, use per-file custom-issuers/*.json, see Custom Issuer Files.

{
  "custom_issuers": {
    "CustomIssuerName": {
      "tokens_mappings": {
        "Custom::ApiKey": {
          "required": true,
          "required_claims": ["sub"]
        },
        "Custom::SessionKey": {}
      }
    }
  }
}
Field Type Description
tokens_mappings map (required) Token types this issuer emits, keyed by Cedar entity type name. The key is matched against the request mapping to route a token to this issuer, so one issuer can emit several token types. Must declare at least one.
tokens_mappings.<type>.required bool (default false) When true, a processing failure (error, timeout, or missing required claim) fails the whole request. When false, the token is dropped and authorization continues without it. Set per token type, so one issuer can mix required and optional tokens.
tokens_mappings.<type>.required_claims string[] (default []) Claims that must be present in the processor output; a missing claim yields MissingRequiredClaim.

Each token type lands under its own context.tokens.{issuer}_{token_type} key, so the example above yields customkeys_apikey and customkeys_sessionkey.

An entity type name may be declared by only one custom issuer; support for several issuers sharing a type is tracked in issue #14747. Custom issuer names must be unique after sanitization and must not collide with a JWT trusted-issuer name since both share the context.tokens key namespace, so a collision fails instance startup.

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"));

        let mut processed = ProcessedTokenClaims::new(claims, "api-key-1");
        processed.cacheable = false; // re-validate on every request (revocation-sensitive)
        Ok(processed)
    }
}

ProcessedTokenClaims fields:

Field Type Description
claims map 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.
issuer_id string? (None) Which custom issuer this token belongs to. None falls back to the sole issuer declaring the mapping (an explicit value is required when several issuers share one mapping).
expiration i64? (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.

ProcessedTokenClaims::new(claims, token_id) builds a cacheable result with no issuer hint or expiration.

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::ApiKeycustomissuername_apikey. Claims are tags; the token entity's iss attribute is the sanitized 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 required: true Whole request fails with the error.
Processor returns Err, token required: false Token dropped, authorization continues.
mapping is custom but no processor registered NoProcessorRegistered (fail-closed if required, else skipped).
A required_claims entry is absent from the output MissingRequiredClaim.
The reported expiration (expiration, else an exp claim) has already passed Expired.
The processor returns an issuer_id that does not declare the requested type UnknownTokenType.
process exceeds the configured timeout (> 0) Timeout.

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 principalauthorize_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#

Configure trusted issuers with the name field for predictable token naming:

{
  "trusted_issuers": {
    "acme_corp_issuer": {
      "name": "AcmeCorp",
      "description": "Acme Corporation Identity Provider",
      "openid_configuration_endpoint": "https://idp.acme.com/.well-known/openid-configuration",
      "token_metadata": {
        "access_token": {
          "entity_type_name": "AcmeCorp::Access_Token",
          "token_id": "jti"
        }
      }
    },
    "google_issuer": {
      "name": "Google",
      "description": "Google Identity Provider",
      "openid_configuration_endpoint": "https://accounts.google.com/.well-known/openid-configuration",
      "token_metadata": {
        "id_token": {
          "entity_type_name": "Google::Id_Token",
          "token_id": "jti"
        }
      }
    },
    "custom_service_issuer": {
      "name": "CustomService",
      "description": "Custom Service Provider",
      "openid_configuration_endpoint": "https://service.example.com/.well-known/openid-configuration",
      "token_metadata": {
        "custom_token": {
          "entity_type_name": "CustomService::ServiceToken",
          "token_id": "jti"
        }
      }
    }
  }
}

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.

Non-Deterministic Tokens#

# ERROR: Multiple tokens of same type from same issuer
tokens = [
    TokenInput(mapping="Jans::Access_Token", payload="token1"),  # From Jans::Access_Token
    TokenInput(mapping="Jans::Access_Token", payload="token2"),  # Also from Jans::Access_Token - ERROR!
]

# This non-deterministic.
# Which token should policies reference?
# Cedarling processes only the first item and writes log messages for all subsequent items that are skipped.

Trusted Issuer Validation#

# Tokens from unknown issuers are rejected
tokens = [
    TokenInput(
        mapping="Jans::Access_Token",
        payload="token_from_unknown_issuer"  # ERROR if issuer not in trusted issuers
    )
]

# Only tokens from issuers configured in policy store are accepted

Best Practices#

1. Use Descriptive Issuer Names#

Configure clear, predictable issuer names in your policy store:

{
  "name": "AcmeCorp", // Good - clear and predictable
  "name": "Issuer1" // Bad - unclear what this represents
}

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#