# AGNTCY Federation — Specification

**Workstream**: 4 of 6
**Phase**: 3 — Enterprise
**Author**: Virtual Architect
**Date**: 2026-03-29

---

## Overview

Enable cross-instance agent identity federation using signed JWT assertions. Operators register trusted remote AgentIdP instances as federation partners. When an agent presents a token issued by a trusted partner instance, the local AgentIdP can verify it by fetching and caching the partner's JWKS. This enables multi-organization agent identity interoperability aligned with AGNTCY standards.

Federation is opt-in per organization. Only tokens from explicitly registered, trusted partners are accepted.

---

## API Endpoints

### POST /federation/trust

Register a new federation trust partner. Requires `admin:orgs` scope.

```yaml
POST /federation/trust
Authorization: Bearer <token with admin:orgs scope>
Content-Type: application/json

Request Body:
  schema:
    type: object
    required: [name, issuer, jwksUri]
    properties:
      name:
        type: string
        minLength: 2
        maxLength: 100
        description: Human-readable name for this federation partner
        example: "Contoso AgentIdP"
      issuer:
        type: string
        format: uri
        description: OIDC issuer URL of the partner instance (must match iss claim in tokens)
        example: "https://agentidp.contoso.com"
      jwksUri:
        type: string
        format: uri
        description: URL of the partner's JWKS endpoint
        example: "https://agentidp.contoso.com/.well-known/jwks.json"
      allowedOrganizations:
        type: array
        items:
          type: string
        description: Optional list of organization IDs in the partner instance whose tokens are accepted. Empty means all partner orgs are trusted.
        example: ["org_contoso_engineering"]
      expiresAt:
        type: string
        format: date-time
        description: Optional expiry for this trust relationship. If omitted, trust does not expire automatically.

Responses:
  201 Created:
    schema:
      $ref: '#/components/schemas/FederationPartner'
    example:
      partnerId: "fed_01HXK7Z9P3FKWABCDEF33333"
      name: "Contoso AgentIdP"
      issuer: "https://agentidp.contoso.com"
      jwksUri: "https://agentidp.contoso.com/.well-known/jwks.json"
      status: "active"
      allowedOrganizations: []
      trustedSince: "2026-03-29T12:00:00Z"
      expiresAt: null
  400 Bad Request:
    schema:
      $ref: '#/components/schemas/ErrorResponse'
    examples:
      duplicate_issuer:
        code: "DUPLICATE_ISSUER"
        message: "A trust relationship with this issuer already exists"
      unreachable_jwks:
        code: "JWKS_UNREACHABLE"
        message: "Could not fetch JWKS from the provided jwksUri"
  401 Unauthorized:
    schema:
      $ref: '#/components/schemas/ErrorResponse'
  403 Forbidden:
    schema:
      $ref: '#/components/schemas/ErrorResponse'
```

---

### GET /federation/partners

List all registered federation partners for the caller's organization. Requires `admin:orgs` scope.

```yaml
GET /federation/partners
Authorization: Bearer <token with admin:orgs scope>

Query Parameters:
  status:
    type: string
    enum: [active, suspended, expired]
  page:
    type: integer
    default: 1
  limit:
    type: integer
    default: 20
    maximum: 100

Responses:
  200 OK:
    schema:
      type: object
      properties:
        data:
          type: array
          items:
            $ref: '#/components/schemas/FederationPartner'
        total:
          type: integer
        page:
          type: integer
        limit:
          type: integer
    example:
      data:
        - partnerId: "fed_01HXK7Z9P3FKWABCDEF33333"
          name: "Contoso AgentIdP"
          issuer: "https://agentidp.contoso.com"
          jwksUri: "https://agentidp.contoso.com/.well-known/jwks.json"
          status: "active"
          trustedSince: "2026-03-29T12:00:00Z"
          expiresAt: null
      total: 1
      page: 1
      limit: 20
  401 Unauthorized:
    schema:
      $ref: '#/components/schemas/ErrorResponse'
  403 Forbidden:
    schema:
      $ref: '#/components/schemas/ErrorResponse'
```

---

### DELETE /federation/partners/:partnerId

Remove a federation trust relationship. Requires `admin:orgs` scope.

```yaml
DELETE /federation/partners/{partnerId}
Authorization: Bearer <token with admin:orgs scope>

Path Parameters:
  partnerId:
    type: string

Responses:
  204 No Content: {}
  401 Unauthorized:
    schema:
      $ref: '#/components/schemas/ErrorResponse'
  403 Forbidden:
    schema:
      $ref: '#/components/schemas/ErrorResponse'
  404 Not Found:
    schema:
      $ref: '#/components/schemas/ErrorResponse'
```

---

### POST /federation/verify

Verify a token issued by a federated partner AgentIdP instance. The caller presents the token; this endpoint resolves the issuer, fetches (or cache-hits) the partner's JWKS, and verifies the signature and claims.

```yaml
POST /federation/verify
Authorization: Bearer <local access_token with agents:read scope>
Content-Type: application/json

Request Body:
  schema:
    type: object
    required: [token]
    properties:
      token:
        type: string
        description: The JWT token issued by the remote AgentIdP instance to verify
      expectedIssuer:
        type: string
        format: uri
        description: Optional — if provided, verification fails if token issuer does not match
      expectedOrganizationId:
        type: string
        description: Optional — if provided, verification fails if token organization_id does not match

Responses:
  200 OK:
    schema:
      type: object
      properties:
        valid:
          type: boolean
        claims:
          type: object
          description: Decoded JWT claims from the verified token
          properties:
            sub:
              type: string
            iss:
              type: string
            iat:
              type: integer
            exp:
              type: integer
            agent_id:
              type: string
            agent_type:
              type: string
            organization_id:
              type: string
            capabilities:
              type: array
              items:
                type: string
            did:
              type: string
        partner:
          type: object
          description: The federation partner record that vouches for this token
          properties:
            partnerId:
              type: string
            name:
              type: string
            issuer:
              type: string
    example:
      valid: true
      claims:
        sub: "agt_contoso_abc123"
        iss: "https://agentidp.contoso.com"
        iat: 1743249600
        exp: 1743253200
        agent_id: "agt_contoso_abc123"
        agent_type: "classifier"
        organization_id: "org_contoso_engineering"
        capabilities: ["text-classification"]
        did: "did:web:agentidp.contoso.com:agents:agt_contoso_abc123"
      partner:
        partnerId: "fed_01HXK7Z9P3FKWABCDEF33333"
        name: "Contoso AgentIdP"
        issuer: "https://agentidp.contoso.com"

  400 Bad Request:
    schema:
      $ref: '#/components/schemas/ErrorResponse'

  401 Unauthorized (local token invalid):
    schema:
      $ref: '#/components/schemas/ErrorResponse'

  422 Unprocessable Entity (token invalid or untrusted issuer):
    schema:
      type: object
      properties:
        valid:
          type: boolean
          example: false
        reason:
          type: string
          enum:
            - TOKEN_EXPIRED
            - INVALID_SIGNATURE
            - UNTRUSTED_ISSUER
            - JWKS_FETCH_FAILED
            - ORGANIZATION_NOT_ALLOWED
        message:
          type: string
    example:
      valid: false
      reason: "UNTRUSTED_ISSUER"
      message: "No trust relationship registered for issuer https://unknown.example.com"
```

---

## Database Schema Changes

### New Table: federation_partners

```sql
CREATE TABLE federation_partners (
  partner_id            VARCHAR(40)   PRIMARY KEY,
  organization_id       VARCHAR(40)   NOT NULL REFERENCES organizations(organization_id),
  name                  VARCHAR(100)  NOT NULL,
  issuer                VARCHAR(255)  NOT NULL,
  jwks_uri              VARCHAR(255)  NOT NULL,
  allowed_organizations JSONB         NOT NULL DEFAULT '[]',
  status                VARCHAR(20)   NOT NULL DEFAULT 'active',
  trusted_since         TIMESTAMPTZ   NOT NULL DEFAULT NOW(),
  expires_at            TIMESTAMPTZ,
  last_jwks_fetch       TIMESTAMPTZ,
  created_at            TIMESTAMPTZ   NOT NULL DEFAULT NOW(),
  updated_at            TIMESTAMPTZ   NOT NULL DEFAULT NOW(),
  CONSTRAINT federation_partners_status_check CHECK (status IN ('active', 'suspended', 'expired')),
  UNIQUE (organization_id, issuer)
);

CREATE INDEX idx_federation_partners_org_id ON federation_partners(organization_id);
CREATE INDEX idx_federation_partners_issuer ON federation_partners(issuer);
CREATE INDEX idx_federation_partners_status ON federation_partners(status);
```

### Redis: JWKS Cache

Partner JWKS documents are cached in Redis with a TTL:

```
Key:   federation:jwks:<issuer_url_sha256>
Value: JSON string of the JWKS document
TTL:   1 hour (configurable via FEDERATION_JWKS_CACHE_TTL_SECONDS)
```

---

## Configuration

| Environment Variable | Description | Default |
|---------------------|-------------|---------|
| `FEDERATION_ENABLED` | Enable federation endpoints | `true` |
| `FEDERATION_JWKS_CACHE_TTL_SECONDS` | Redis TTL for cached partner JWKS | `3600` |
| `FEDERATION_JWKS_FETCH_TIMEOUT_MS` | HTTP timeout for fetching partner JWKS | `5000` |
| `FEDERATION_MAX_PARTNERS_PER_ORG` | Max federation partners per organization | `50` |

---

## Dependencies

No new npm packages. Federation uses `jsonwebtoken` (already present) for JWT verification and the existing HTTP client for JWKS fetches.

---

## Security Considerations

- Only tokens from explicitly registered, active federation partners are accepted in `POST /federation/verify`
- JWKS are cached to prevent JWKS endpoint hammering; cache is invalidated when a partner is updated
- Token signature verification uses the partner's JWKS; `alg: none` is always rejected
- `allowedOrganizations` field enables fine-grained trust: a partner can be trusted but only for tokens from specific organizations within that partner
- Expired federation partners (`expiresAt` in the past) are automatically treated as status `expired` — their tokens are rejected
- `POST /federation/verify` does not grant any local permissions — it is a verification-only endpoint. Callers must make their own access control decisions based on the returned claims.
- Clock skew tolerance: `exp` claim verification allows 30 seconds of clock skew (standard JWT practice)

---

## Acceptance Criteria

- [ ] `POST /federation/trust` registers a partner and fetches JWKS; returns 400 if JWKS unreachable
- [ ] `POST /federation/verify` returns `valid: true` for a correctly signed token from a trusted partner
- [ ] `POST /federation/verify` returns `valid: false` with `reason: UNTRUSTED_ISSUER` for unknown issuers
- [ ] `POST /federation/verify` returns `valid: false` with `reason: TOKEN_EXPIRED` for expired tokens
- [ ] Expired trust relationships (past `expiresAt`) are rejected automatically
- [ ] JWKS cache hit is used on second verification request for same issuer (Redis key present)
- [ ] TypeScript strict, zero `any`, >80% test coverage on FederationService
