IdP Integration in Plugins
Master token flows, On-Behalf-Of (OBO) patterns, token introspection, DPoP tokens, and user context management in plugins.
๐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.
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
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.
(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: 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
- Client generates a public/private key pair
- Client creates a DPoP proof JWT signed with private key
- Token request includes the DPoP proof
- IdP returns a token bound to that key (
cnf.jktclaim) - Every API call includes a fresh DPoP proof
- 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;
};
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
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
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.
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.
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.
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.
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
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.