Scaffolds the phase-3-enterprise OpenSpec change (proposal only — awaiting CEO approval before implementation). 6 workstreams, 95 implementation tasks: WS1: Multi-Tenancy (21 tasks) — org model, RLS, admin API WS2: W3C DIDs (12 tasks) — DID:WEB, agent DID documents, AGNTCY cards WS3: OIDC (12 tasks) — oidc-provider, ID tokens, JWKS, discovery WS4: Federation (11 tasks) — cross-instance trust, JWT assertions WS5: Webhooks (17 tasks) — subscriptions, Bull queue, HMAC, retry WS6: SOC2 (22 tasks) — encryption at rest, Merkle audit chain, controls Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
371 lines
11 KiB
Markdown
371 lines
11 KiB
Markdown
# 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
|