npm.io
0.12.0 • Published 20h ago

@datacules/agent-identity-token-exchange

Licence
SEE LICENSE IN LICENSE
Version
0.12.0
Deps
0
Size
43 kB
Vulns
0
Weekly
5
Stars
1

Agent Identity — by Datacules LLC

@datacules/agent-identity-token-exchange

RFC 8693 OAuth 2.0 Token Exchange CredentialStore for @datacules/agent-identity.

Exchanges a user's existing access or ID token for a narrowly-scoped downstream token at any compliant Authorization Server — without storing any long-lived credentials.


The problem it solves

In many enterprise deployments an AI agent must call downstream services (CRM, ERP, data warehouse) on behalf of a specific user, but the user's primary OAuth token is either too broad (wrong audience, excessive scopes) or was issued by a different AS than the one the downstream service trusts. The agent cannot use the user's token directly and cannot store per-user long-lived secrets at scale.

Token exchange (RFC 8693) lets the agent present the user's existing token to a trusted AS and receive a new token scoped precisely to the downstream service — in one HTTP call, with no stored secrets.


How it fits into agent-identity

TokenExchangeStore implements the standard CredentialStore interface, so it drops in anywhere a MemoryCredentialStore, VaultCredentialStore, or AwsCredentialStore would be used. The routing layer remains unchanged; only the store changes.

import { TokenExchangeStore } from '@datacules/agent-identity-token-exchange';
import { createRouterFromStore } from '@datacules/agent-identity';

// Closed over the current request's authenticated user token:
const subjectToken = req.headers.authorization?.replace('Bearer ', '');

const store = new TokenExchangeStore({
  configs: exchangeConfigs,
  subjectTokenProvider: async (_ref) => subjectToken ?? null,
});

const router  = createRouterFromStore(store, rules, logger);
const resolved = await router.resolveAsync(ctx);
// resolved.ref IS the exchanged access_token — injected server-side,
// never returned to the model or the client.

Installation

npm install @datacules/agent-identity-token-exchange
# peer dependency:
npm install @datacules/agent-identity

Quick start

1. Define exchange configurations
import type { TokenExchangeConfig } from '@datacules/agent-identity-token-exchange';

export const exchangeConfigs: TokenExchangeConfig[] = [
  {
    // Matches the credentialRef in your RoutingRule
    ref:             'crm-service-token',
    name:            'CRM Service Token',
    kind:            'user-delegated',
    scope:           'crm:read crm:write',
    status:          'active',
    provider:        'openai',

    // Keycloak example
    tokenEndpoint:   'https://auth.acme.com/realms/prod/protocol/openid-connect/token',
    clientId:        'agent-identity',
    clientSecret:    process.env.AGENT_CLIENT_SECRET!,
    requestedScopes: ['crm:read', 'crm:write'],
    audience:        'https://crm.acme.com',
  },
];
2. Create routing rules
import type { RoutingRule } from '@datacules/agent-identity';

export const rules: RoutingRule[] = [
  {
    id:             'rule-crm-user-delegated',
    description:    'Route CRM calls through token exchange',
    credentialRef:  'crm-service-token',   // matches TokenExchangeConfig.ref
    credentialKind: 'user-delegated',
    priority:       80,
    matchProvider:  'openai',
    matchAction:    ['read', 'write'],
  },
];
3. Wire into your API route
// src/app/api/resolve/route.ts (or Express / Fastify handler)
import { TokenExchangeStore } from '@datacules/agent-identity-token-exchange';
import { createRouterFromStore } from '@datacules/agent-identity';
import { exchangeConfigs } from '@/lib/exchangeConfigs';
import { rules }           from '@/lib/rules';

export async function POST(req: Request) {
  const body         = await req.json();
  const subjectToken = req.headers.get('authorization')?.replace('Bearer ', '');

  const store = new TokenExchangeStore({
    configs:              exchangeConfigs,
    subjectTokenProvider: async (_ref) => subjectToken ?? null,
  });

  const router   = createRouterFromStore(store, rules);
  const resolved = await router.resolveAsync(body);

  if (!resolved) return Response.json({ error: 'Unauthorized' }, { status: 403 });
  return Response.json({ credentialId: resolved.credentialId, resolvedFor: resolved.resolvedFor });
}

Authorization Server examples

Keycloak
{
  tokenEndpoint: `https://keycloak.acme.com/realms/${REALM}/protocol/openid-connect/token`,
  clientId:      'agent-identity',
  clientSecret:  process.env.KC_CLIENT_SECRET!,
  // Keycloak: enable token exchange in realm settings
  extraParams:   { requested_issuer: 'external-idp' }, // optional
}
Auth0 (Enterprise)
{
  tokenEndpoint: `https://${DOMAIN}/oauth/token`,
  clientId:      process.env.AUTH0_CLIENT_ID!,
  clientSecret:  process.env.AUTH0_CLIENT_SECRET!,
  audience:      'https://api.acme.com',
  requestedScopes: ['read:crm'],
}
Azure AD / Entra ID (On-Behalf-Of)
{
  tokenEndpoint:    `https://login.microsoftonline.com/${TENANT_ID}/oauth2/v2.0/token`,
  clientId:         process.env.AZURE_CLIENT_ID!,
  clientSecret:     process.env.AZURE_CLIENT_SECRET!,
  requestedScopes:  ['https://graph.microsoft.com/.default'],
  subjectTokenType: RFC_TOKEN_TYPES.ACCESS_TOKEN,
  // Azure OBO uses the same grant_type as RFC 8693
  extraParams:      { requested_token_use: 'on_behalf_of' },
}
Okta
{
  tokenEndpoint: `https://${OKTA_DOMAIN}/oauth2/${AUTH_SERVER_ID}/v1/token`,
  clientId:      process.env.OKTA_CLIENT_ID!,
  clientSecret:  process.env.OKTA_CLIENT_SECRET!,
  requestedScopes: ['crm:read'],
  audience:        'https://crm.acme.com',
}

Cache management

Exchanged tokens are cached in-memory per store instance until 30 seconds before their expires_in-derived expiry.

// Force re-exchange for a specific slot (e.g. after a 401 from downstream):
store.invalidateCache('crm-service-token');

// Flush all cached tokens:
store.flushCache();

API reference

TokenExchangeStore
Method Signature Description
findByRef (ref: string) => Promise<Credential | null> Exchange subject token; returns credential with exchanged token as ref
listActive () => Promise<Credential[]> All configs with status: 'active'
listByKind (kind: CredentialKind) => Promise<Credential[]> Filter by 'fixed' or 'user-delegated'
invalidateCache (ref: string) => void Evict one cached token
flushCache () => void Evict all cached tokens

Part of the agent-identity monorepo by Datacules LLC. Datacules Open Source License.

Keywords