DAY 17 WEEK 4: INTEGRATION

IdP Integration in Plugins

Master token flows, On-Behalf-Of (OBO) patterns, token introspection, DPoP tokens, and user context management in plugins.

Your plugin lives inside the Experience Platform, which handles authentication. But what if your plugin needs to call external APIs? What if a backend service needs to act on behalf of the user? Today we'll master the token flows that make secure cross-service communication possible.

๐Ÿ“‹Learning Objectives

  • Understand how tokens flow through the platform
  • Access user identity and claims in plugins
  • Implement token exchange for external services
  • Use On-Behalf-Of (OBO) for service-to-service calls
  • Work with DPoP (Demonstration of Proof-of-Possession)
  • Implement token introspection and validation

๐Ÿ—๏ธToken Flow Architecture

Understanding how tokens flow through the platform is essential for secure plugin development.

sequenceDiagram participant U as User participant EXP as Experience Platform participant BFF as BFF (bff.self) participant IdP as Identity Provider participant Plugin as Your Plugin participant Backend as Backend Service participant Ext as External API U->>EXP: Login EXP->>IdP: Authorization Code Flow IdP-->>EXP: Access Token + ID Token EXP->>EXP: Store tokens (httpOnly cookie) Note over EXP,Plugin: Plugin loads with platform context Plugin->>BFF: API call (cookie auto-attached) BFF->>BFF: Extract token from cookie BFF->>Backend: Forward with Authorization header Note over Backend,Ext: If backend needs to call external API as user Backend->>IdP: Token Exchange (OBO) IdP-->>Backend: New token for external API Backend->>Ext: Call with exchanged token

Key Token Types

Token Type Purpose Lifetime
Access Token Authorize API calls 15-60 minutes
ID Token User identity claims 15-60 minutes
Refresh Token Obtain new access tokens Hours to days
DPoP Token Proof of possession (bound to key) Same as access token

๐Ÿ‘คAccessing User Context in Plugins

The platform provides user identity through the Plugin SDK. Plugins don't directly access tokensโ€”the BFF handles that.

Get Current User

// src/components/UserProfile.tsx
const React = (globalThis as any).React;
const { useState, useEffect } = React;
const sdk = (globalThis as any).pluginSdk;

export function UserProfile() {
  const [user, setUser] = useState<any>(null);
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    fetchUser();
  }, []);

  const fetchUser = async () => {
    try {
      // Get user info from IdP userinfo endpoint (via BFF)
      const response = await sdk.api.fetch('/api/idp/userinfo');
      const data = await response.json();
      setUser(data);
    } catch (err) {
      console.error('Failed to fetch user:', err);
    } finally {
      setLoading(false);
    }
  };

  if (loading) return React.createElement('div', null, 'Loading...');
  if (!user) return React.createElement('div', null, 'Not authenticated');

  return React.createElement('div', { style: profileStyle },
    React.createElement('img', { 
      src: user.picture, 
      alt: user.name,
      style: avatarStyle 
    }),
    React.createElement('div', null,
      React.createElement('h3', null, user.name),
      React.createElement('p', null, user.email),
      React.createElement('p', { style: { fontSize: '12px', color: '#888' } },
        'Subject: ', user.sub
      )
    )
  );
}

User Info Response Structure

{
  "sub": "user-12345-abcde",
  "name": "Jane Developer",
  "given_name": "Jane",
  "family_name": "Developer",
  "email": "jane@company.com",
  "email_verified": true,
  "picture": "https://...",
  "locale": "en-US",
  "zoneinfo": "America/New_York",
  
  // Custom claims (tenant-specific)
  "tenant_id": "tenant-xyz",
  "department": "Engineering",
  "employee_id": "EMP-1234"
}

Access Token Claims (for backend services)

// JWT Access Token payload (decoded)
{
  "iss": "https://idp.self.empowernow.ai",
  "sub": "user-12345-abcde",
  "aud": ["bff.self.empowernow.ai", "membership"],
  "exp": 1706012400,
  "iat": 1706008800,
  "azp": "experience-platform",
  "scope": "openid profile email membership:read",
  
  // Client info
  "client_id": "experience-platform",
  
  // Session binding
  "sid": "session-abc123",
  
  // DPoP confirmation (if DPoP enabled)
  "cnf": {
    "jkt": "sha256-thumbprint-of-dpop-key"
  }
}

๐Ÿ”„Token Exchange

What if your plugin needs to call an external API that requires its own token? Token exchange lets you trade your current token for one with a different audience or scope.

When to Use Token Exchange

  • Calling external APIs that are part of your organization
  • Getting a token with additional/different scopes
  • Downscoping a token for limited access

Token Exchange via BFF

// Plugin requests token exchange through BFF
const exchangeToken = async (targetAudience: string, scopes: string[]) => {
  const response = await sdk.api.fetch('/api/idp/token/exchange', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
      target_audience: targetAudience,
      scopes: scopes
    })
  });
  
  if (!response.ok) {
    throw new Error('Token exchange failed');
  }
  
  return await response.json();
};

// Usage
const callExternalApi = async () => {
  // Exchange for external API token
  const { access_token } = await exchangeToken(
    'external-reporting-api',
    ['reports:read', 'reports:export']
  );
  
  // Call external API with new token
  const response = await fetch('https://reports.company.com/api/data', {
    headers: { 'Authorization': `Bearer ${access_token}` }
  });
  
  return await response.json();
};

Backend Token Exchange Request

# RFC 8693 Token Exchange
curl -X POST https://idp.self.empowernow.ai/api/oidc/token \
  -H "Content-Type: application/x-www-form-urlencoded" \
  -d "grant_type=urn:ietf:params:oauth:grant-type:token-exchange" \
  -d "subject_token=$CURRENT_ACCESS_TOKEN" \
  -d "subject_token_type=urn:ietf:params:oauth:token-type:access_token" \
  -d "audience=external-reporting-api" \
  -d "scope=reports:read reports:export" \
  -d "client_id=$CLIENT_ID" \
  -d "client_secret=$CLIENT_SECRET"

# Response
{
  "access_token": "eyJhbGciOiJSUzI1NiI...",
  "token_type": "Bearer",
  "expires_in": 3600,
  "scope": "reports:read reports:export",
  "issued_token_type": "urn:ietf:params:oauth:token-type:access_token"
}

๐Ÿ‘ฅOn-Behalf-Of (OBO) Flow

OBO is used when a backend service needs to call another service as the user, maintaining the user's identity and permissions throughout the call chain.

sequenceDiagram participant Plugin as Plugin participant BFF as BFF participant Svc1 as Service A participant IdP as IdP participant Svc2 as Service B Plugin->>BFF: Request (user token in cookie) BFF->>Svc1: Forward (Authorization: Bearer user_token) Note over Svc1: Service A needs to call Service B as user Svc1->>IdP: OBO Token Request
(assertion=user_token) IdP->>IdP: Validate token
Check OBO policy IdP-->>Svc1: New token for Service B Svc1->>Svc2: Call with OBO token Svc2-->>Svc1: Response Svc1-->>BFF: Aggregated response BFF-->>Plugin: Final response

OBO Request (Backend Service)

# Service A requests OBO token to call Service B
curl -X POST https://idp.self.empowernow.ai/api/oidc/token \
  -H "Content-Type: application/x-www-form-urlencoded" \
  -d "grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer" \
  -d "assertion=$USER_ACCESS_TOKEN" \
  -d "client_id=service-a" \
  -d "client_secret=$SERVICE_A_SECRET" \
  -d "scope=service-b:read service-b:write" \
  -d "requested_token_use=on_behalf_of"

# Response
{
  "access_token": "eyJhbGciOiJSUzI1NiI...",
  "token_type": "Bearer",
  "expires_in": 3600,
  "scope": "service-b:read service-b:write"
}

OBO Token Structure

// OBO token maintains user identity but changes audience
{
  "iss": "https://idp.self.empowernow.ai",
  "sub": "user-12345-abcde",           // Same user!
  "aud": "service-b",                  // New audience
  "azp": "service-a",                  // Authorized party (who got the token)
  "scope": "service-b:read service-b:write",
  
  // OBO chain tracking
  "act": {
    "sub": "service-a",                // Acting party
    "client_id": "service-a"
  }
}
๐Ÿ“ OBO vs Token Exchange

OBO: Service acts as the user. User identity preserved. Used for service-to-service calls where the downstream service needs to know who the original user is.

Token Exchange: Get a token for a different audience/scope. Can be used by the client directly or by services. More flexible but doesn't preserve the "acting party" chain.

๐Ÿ”DPoP (Demonstration of Proof-of-Possession)

DPoP binds tokens to a cryptographic key, preventing token theft. If someone steals your access token, they can't use it without the private key.

How DPoP Works

  1. Client generates a public/private key pair
  2. Client creates a DPoP proof JWT signed with private key
  3. Token request includes the DPoP proof
  4. IdP returns a token bound to that key (cnf.jkt claim)
  5. Every API call includes a fresh DPoP proof
  6. Server validates proof matches token binding

DPoP Proof Structure

// DPoP proof JWT header
{
  "typ": "dpop+jwt",
  "alg": "ES256",
  "jwk": {
    "kty": "EC",
    "crv": "P-256",
    "x": "base64url-encoded-x",
    "y": "base64url-encoded-y"
  }
}

// DPoP proof JWT payload
{
  "jti": "unique-identifier-abc123",
  "htm": "POST",                        // HTTP method
  "htu": "https://api.example.com/resource",  // HTTP URI
  "iat": 1706008800,
  "ath": "sha256-hash-of-access-token"  // Access token binding
}

Making DPoP-Protected Requests

// BFF handles DPoP for plugins automatically
// But if you're building a backend service:

import { createDPoPProof } from '@empowernow/auth-utils';

const callApiWithDPoP = async (url: string, accessToken: string, privateKey: CryptoKey) => {
  // Generate fresh DPoP proof for this request
  const dpopProof = await createDPoPProof({
    method: 'GET',
    url: url,
    accessToken: accessToken,  // For ath claim
    privateKey: privateKey
  });
  
  const response = await fetch(url, {
    headers: {
      'Authorization': `DPoP ${accessToken}`,
      'DPoP': dpopProof
    }
  });
  
  return response;
};
๐Ÿ’ก Plugin DPoP

Plugins don't need to implement DPoP directly. The Experience Platform and BFF handle DPoP tokens automatically. However, understanding DPoP helps when debugging authentication issues or building backend services.

๐Ÿ”Token Introspection

Token introspection lets a service verify a token and get its claims without decoding the JWT locally. Useful for opaque tokens or when you need real-time validity checks.

Introspection Request

# Introspect an access token
curl -X POST https://idp.self.empowernow.ai/api/oidc/introspect \
  -H "Content-Type: application/x-www-form-urlencoded" \
  -u "$CLIENT_ID:$CLIENT_SECRET" \
  -d "token=$ACCESS_TOKEN" \
  -d "token_type_hint=access_token"

# Response (active token)
{
  "active": true,
  "sub": "user-12345-abcde",
  "client_id": "experience-platform",
  "username": "jane@company.com",
  "scope": "openid profile email",
  "exp": 1706012400,
  "iat": 1706008800,
  "aud": ["bff.self.empowernow.ai"],
  "iss": "https://idp.self.empowernow.ai",
  "token_type": "Bearer"
}

# Response (inactive/expired token)
{
  "active": false
}

When to Use Introspection

  • Opaque tokens: When token isn't a JWT you can decode
  • Real-time revocation: Check if token was revoked
  • Stateless services: When you don't have signing keys
  • Audit logging: Get authoritative token claims
โš ๏ธ Performance Consideration

Introspection adds latency (network call to IdP). For high-throughput services, prefer local JWT validation with cached signing keys. Use introspection for sensitive operations or periodic re-validation.

๐ŸŽฏScopes vs PDP Permissions

Understanding when to use OAuth scopes vs PDP authorization is crucial.

Aspect OAuth Scopes PDP Permissions
Granularity Coarse (API-level) Fine (resource/action)
When evaluated Token issuance Runtime (each request)
Stored in Token itself Policy engine
Example membership:read role:finance-admin:assign
Use for API access gates Business logic authorization

Combined Pattern in Plugins

// 1. Scopes gate API access (handled by BFF/backend)
// Token must have 'membership:write' scope to call this endpoint
const response = await sdk.api.fetch('/api/membership/roles', {
  method: 'POST',
  body: JSON.stringify({ name: 'New Role' })
});

// 2. PDP gates specific actions (checked in plugin UI)
const canAssign = await sdk.authz.evaluate({
  resource: { type: 'role', id: roleId },
  action: { name: 'assign' },
  context: { target_user: targetUserId }
});

if (canAssign.decision) {
  // Show assign button
}

๐Ÿ”งTroubleshooting

Issue: "Token expired" errors

# Symptoms: 401 errors, "token_expired" error code

# Check token expiration
# Decode JWT and check 'exp' claim
echo $ACCESS_TOKEN | cut -d'.' -f2 | base64 -d | jq '.exp'

# Solutions:
# 1. BFF should auto-refresh - check refresh token validity
# 2. User may need to re-login if refresh token also expired
# 3. Check clock sync between servers

Issue: "Invalid audience" on token exchange

# Symptom: Token exchange fails with "invalid_target"

# Check:
# 1. Target audience must be a registered client
curl -k https://idp.self.empowernow.ai/api/oidc/clients/$TARGET_AUDIENCE

# 2. Client must allow token exchange
# In client config: "allowed_token_exchange_targets": ["your-client"]

# 3. Scopes must be allowed for that client

Issue: OBO fails with "unauthorized_client"

# Symptom: OBO request returns "unauthorized_client" error

# Check:
# 1. Service client must have OBO grant type enabled
# grant_types must include "urn:ietf:params:oauth:grant-type:jwt-bearer"

# 2. Client must be allowed to request OBO for target scopes
# "allowed_obo_scopes": ["service-b:*"]

# 3. Original token must have been issued to an allowed client
# "allowed_obo_clients": ["experience-platform"]

Issue: DPoP proof validation failed

# Symptoms: "invalid_dpop_proof" error

# Common causes:
# 1. 'htm' doesn't match HTTP method
# 2. 'htu' doesn't match request URL (exact match required)
# 3. 'iat' too old (proof should be fresh, within minutes)
# 4. 'jti' reused (must be unique per request)
# 5. Key thumbprint doesn't match token's 'cnf.jkt'

โ“Knowledge Check

1 When should you use OBO vs token exchange? โ–ผ

OBO: When a service needs to call another service as the user, preserving user identity. The downstream service needs to authorize based on who the user is.

Token Exchange: When you need a token for a different audience/scope. Can be service-to-service or client-initiated. More flexible but doesn't track the "acting party" chain.

2 Why don't plugins directly access tokens? โ–ผ

Tokens are stored in httpOnly cookies managed by the BFF. This prevents JavaScript (including plugins) from accessing them, protecting against XSS token theft. Plugins call APIs through the BFF, which attaches tokens automatically.

3 What does the 'cnf.jkt' claim indicate? โ–ผ

It's the SHA-256 thumbprint of the DPoP public key. The token is "bound" to this keyโ€”API calls must include a DPoP proof signed by the matching private key. This prevents token theft because the attacker would also need the private key.

4 What's the difference between scopes and PDP permissions? โ–ผ

Scopes: Coarse-grained, stored in token, evaluated at token issuance. Example: membership:read gates access to the membership API.

PDP Permissions: Fine-grained, stored in policy engine, evaluated at runtime. Example: role:finance-admin:assign controls whether this user can assign this specific role.

5 When should you use token introspection? โ–ผ

Use introspection when:

  • Token is opaque (not a JWT)
  • You need real-time revocation checks
  • Service doesn't have IdP signing keys
  • Sensitive operations need authoritative claims

Avoid for high-throughput paths due to latency.

๐Ÿ“Day 17 Checkpoint

  • Understand token flow through the platform
  • Can access user info via Plugin SDK
  • Know when to use token exchange vs OBO
  • Understand DPoP and proof-of-possession
  • Can use token introspection for validation
  • Know the difference between scopes and PDP permissions
๐ŸŽ‰ What You Learned

You now understand the complete token lifecycle in EmpowerNowโ€”from initial authentication through token exchange, OBO flows, and DPoP protection. This knowledge is essential for building secure plugins that integrate with external services.