Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ This repo includes examples for:
| `/postUserAuthentication` | a user completes single factor authentication (e.g Google auth) |
| `/preMFA` | before checking if MFA is required |
| `/userTokens` | ID and access tokens are generated |
| `/captureIdpClaimsToTokens` | ID and access tokens are generated |
| `/planSelection` | A user tries to change plan |
| `/planCancellationRequest` | A user requests to cancel their subscription |

Expand All @@ -39,6 +40,7 @@ This repo includes examples for:
- [TrustPath impossible travel](https://github.com/kinde-starter-kits/workflow-examples/blob/main/postUserAuthentication/impossibleTravelWorkflow.ts) - Evaluate user login risk using TrustPath's API by checking for "impossible travel" patterns based on IP and recent login activity. If high risk is detected, access is blocked proactively.
- [Set a grace period for MFA](https://github.com/kinde-starter-kits/workflow-examples/blob/main/preMFA/gracePeriodWorkflow.ts) - Don't ask for MFA for a set period of time after a user has logged in.
- [Add custom claims to access token](https://github.com/kinde-starter-kits/workflow-examples/blob/main/userTokens/customClaimsAccessTokenWorkflow.ts) - Call an external API to get data to add as custom claims to the user access token.
- [Capture IdP claims to tokens](https://github.com/kinde-starter-kits/workflow-examples/blob/main/captureIdpClaimsToTokens/CaptureIdpClaimsWorkflow.ts) - Automatically capture ALL claims from social identity providers (Google, Microsoft, etc.) and include them in user tokens without hardcoding specific claim names.
- [Map M2M applications to organizations](https://github.com/kinde-starter-kits/workflow-examples/blob/main/m2mToken/mapOrgToM2MApplicationWorkflow.ts) - Shows how to map M2M applications to organizations. Useful if using Kinde for B2B API key management
- [Deny plan change](https://github.com/kinde-starter-kits/workflow-examples/blob/main/planSelection/denyPlanChangeWorkflow.ts) - Prevent a user from changing plans. Useful if they aren't eligible to if in breach of limits
- [Deny plan cancellation](https://github.com/kinde-starter-kits/workflow-examples/blob/main/planCancellationRequest/denyPlanCancellation.ts) - Prevent a user from cancelling their plan. Useful if you need to do manual deprovisioning
Expand Down
144 changes: 144 additions & 0 deletions captureIdpClaimsToTokens/AddIdpClaimsToTokensWorkflow.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
import {
onTokensGenerationEvent,
WorkflowSettings,
WorkflowTrigger,
accessTokenCustomClaims,
idTokenCustomClaims,
createKindeAPI,
} from "@kinde/infrastructure";

/**
* Add IdP Claims to Tokens Workflow
*
* This workflow reads IdP claims that were captured during authentication
* (stored in the `idp_claims` user property) and adds them to tokens.
*
* This workflow works in conjunction with CaptureIdpClaimsWorkflow:
* 1. CaptureIdpClaimsWorkflow (PostAuthentication) - captures IdP claims and stores them
* 2. This workflow (TokensGeneration) - reads stored claims and adds them to tokens
*
* Benefits:
* - Automatically includes ALL IdP claims in tokens (no hardcoding needed)
* - Claims persist across token refreshes
* - Easy to customize which claims go into which tokens
* - Works with any OAuth2/OIDC provider
*
* Setup:
* 1. Create a Machine-to-Machine (M2M) application in Kinde with this scope:
* - read:user_properties
*
* 2. Add these environment variables to your workflow:
* - KINDE_WF_M2M_CLIENT_ID (from your M2M app)
* - KINDE_WF_M2M_CLIENT_SECRET (from your M2M app, mark as sensitive)
*
* 3. Deploy the CaptureIdpClaimsWorkflow first (PostAuthentication trigger)
* 4. Deploy this workflow (TokensGeneration trigger)
* 5. Authenticate via a social connection to test
* 6. Your tokens will include all captured IdP claims with "idp_" prefix
*
* Example token claims after deployment:
* - idp_email: user's email from IdP
* - idp_name: user's full name from IdP
* - idp_sub: user's unique ID at the IdP
* - idp_picture: user's profile picture URL
* - Plus any other claims provided by the IdP
*
* Note: This workflow runs on EVERY token generation, not just initial authentication.
* The claims are fetched from stored user properties via Management API.
*
* Trigger: user:tokens_generation
*/

export const workflowSettings: WorkflowSettings = {
id: "addIdpClaimsToTokens",
name: "Add IdP Claims to Tokens",
trigger: WorkflowTrigger.UserTokenGeneration,
failurePolicy: {
action: "stop",
},
bindings: {
"kinde.accessToken": {
audience: [],
},
"kinde.idToken": {},
"kinde.env": {},
"url": {},
},
};

export default async function handleTokensGeneration(event: onTokensGenerationEvent) {
// Get user ID from the event
const userId = event.user?.id || event.context?.user?.id;

if (!userId) {
console.error("User ID is missing from event");
return;
}

// Create the Kinde API client to fetch user properties
const kindeAPI = await createKindeAPI(event);

// Fetch user properties via Management API
let userProperties: Record<string, any> = {};

try {
const propertiesResponse = await kindeAPI.get({
endpoint: `users/${userId}/properties`,
});

// The response structure is: { data: { properties: [...] } }
const properties = propertiesResponse?.data?.properties || propertiesResponse?.properties;

// Convert properties array to a key-value object
if (properties && Array.isArray(properties)) {
for (const prop of properties) {
userProperties[prop.key] = prop.value;
}
}
} catch (error: any) {
console.error("Failed to fetch user properties:", error?.message || error);
return;
}

// Check if the idp_claims property exists
if (!userProperties.idp_claims) {
// User hasn't authenticated via IdP or CaptureIdpClaimsWorkflow isn't deployed
return;
}

// Parse the JSON from the idp_claims property
let idpClaims: Record<string, any>;
try {
idpClaims = JSON.parse(userProperties.idp_claims as string);
} catch (error) {
console.error("Failed to parse idp_claims property:", error);
return;
}

// Initialize token custom claims with dynamic types
const accessToken = accessTokenCustomClaims<Record<string, any>>();
const idToken = idTokenCustomClaims<Record<string, any>>();

// Add all IdP claims to tokens with "idp_" prefix
// Skip metadata fields (those starting with "_")
let claimsAdded = 0;

for (const [claimName, claimValue] of Object.entries(idpClaims)) {
// Skip metadata fields like _provider and _last_updated
if (claimName.startsWith('_')) {
continue;
}

// Add to both access and ID tokens with "idp_" prefix
const tokenClaimName = `idp_${claimName}`;
accessToken[tokenClaimName] = claimValue;
idToken[tokenClaimName] = claimValue;

claimsAdded++;
}

if (claimsAdded > 0) {
console.log(`Added ${claimsAdded} IdP claims to tokens (provider: ${idpClaims._provider || 'unknown'})`);
}
}

195 changes: 195 additions & 0 deletions captureIdpClaimsToTokens/CaptureIdpClaimsWorkflow.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,195 @@
/**
* Capture IdP Claims Workflow
*
* This workflow captures ALL claims from social identity providers (Google, Microsoft, etc.)
* and stores them in a single user property as JSON.
*
* This workflow works in conjunction with AddIdpClaimsToTokensWorkflow:
* 1. This workflow (PostAuthentication) - captures and stores IdP claims
* 2. AddIdpClaimsToTokensWorkflow (TokensGeneration) - reads stored claims and adds them to tokens
*
* Benefits:
* - Automatically captures all IdP claims without hardcoding specific claim names
* - Only creates ONE property instead of many individual properties
* - Preserves original IdP token structure and values
* - New claims from IdP are automatically captured without code changes
*
* Setup:
* 1. Create a Machine-to-Machine (M2M) application in Kinde with these scopes:
* - read:properties
* - create:properties
* - update:user_properties
*
* 2. Add these environment variables to your workflow:
* - KINDE_WF_M2M_CLIENT_ID (from your M2M app)
* - KINDE_WF_M2M_CLIENT_SECRET (from your M2M app, mark as sensitive)
*
* 3. Get your property category ID:
* - Go to Settings → Properties → Categories in Kinde dashboard
* - Create a category (e.g., "Identity Provider") or use an existing one
* - Update the PROPERTY_CATEGORY_ID constant below with your category ID
*
* 4. Deploy this workflow
* 5. Deploy the AddIdpClaimsToTokensWorkflow (companion workflow)
* 6. Authenticate via a social connection to test
*
* Supported Providers:
* - Any OAuth2/OIDC provider (Google, Microsoft, GitHub, etc.)
*
* Trigger: user:post_authentication
*/

import {
WorkflowSettings,
WorkflowTrigger,
createKindeAPI,
} from "@kinde/infrastructure";

export const workflowSettings: WorkflowSettings = {
id: "captureIdpClaimsJson",
name: "Capture IdP Claims as JSON",
failurePolicy: {
action: "stop",
},
trigger: WorkflowTrigger.PostAuthentication,
bindings: {
"kinde.env": {},
"url": {}
},
};

export default async function captureIdpClaimsWorkflow(event: any) {
const provider = event.context?.auth?.provider;

// Only process OAuth2/OIDC social connections
if (!provider || provider.protocol !== "oauth2") {
console.log("Not an OAuth2 authentication, skipping");
return;
}

const idTokenClaims = provider.data?.idToken?.claims;

if (!idTokenClaims) {
console.log("No ID token claims available");
return;
}

const userId = event.context?.user?.id;

if (!userId) {
console.error("User ID is missing from event context");
throw new Error("User ID is required");
}

// Create the Kinde API client
const kindeAPI = await createKindeAPI(event);

// CONFIGURATION: Update this with your property category ID
// Find your category ID in: Settings → Properties → Categories
const PROPERTY_CATEGORY_ID = "Add the category ID here"; // TODO: Replace with your category ID
const IDP_CLAIMS_PROPERTY_KEY = "idp_claims";

// Check if the idp_claims property exists, create it if not
let propertyExists = false;

try {
const propertiesResponse = await kindeAPI.get({
endpoint: 'properties',
params: {
context: 'usr', // Filter for user properties only
},
});

propertyExists = (propertiesResponse.properties || []).some(
(prop: any) => prop.key === IDP_CLAIMS_PROPERTY_KEY
);
} catch (error) {
console.error("Error fetching existing properties:", error);
throw error;
}

// Create the property if it doesn't exist
if (!propertyExists) {
try {
await kindeAPI.post({
endpoint: 'properties',
params: {
key: IDP_CLAIMS_PROPERTY_KEY,
name: "IdP Claims",
description: "All claims from the identity provider stored as JSON",
type: 'multi_line_text',
context: 'usr',
is_private: false, // Can be included in tokens if needed
category_id: PROPERTY_CATEGORY_ID,
},
});

console.log(`Created property: ${IDP_CLAIMS_PROPERTY_KEY}`);
} catch (error: any) {
console.error(`Failed to create property '${IDP_CLAIMS_PROPERTY_KEY}':`, error?.message || error);
throw error;
}
Comment thread
Koosha-Owji marked this conversation as resolved.
}

// Step 3: Filter out claims we don't want to store
const ignoreClaims = new Set([
// Standard JWT claims (not useful to store)
'iss', // Issuer
'aud', // Audience
'exp', // Expiration time
'iat', // Issued at
'nbf', // Not before
'jti', // JWT ID
'azp', // Authorized party
'nonce', // Nonce for replay protection
'auth_time', // Authentication time
'at_hash', // Access token hash
'c_hash', // Code hash

// Microsoft-specific noise claims
'aio', // Microsoft internal state token (very long, not useful)
'ver', // Token version
'rh', // Microsoft refresh token hint
'uti', // Microsoft unique token identifier (internal use)
'ipaddr', // IP address (privacy concern, changes frequently)

// Google-specific noise claims
'nonce', // Already in standard claims

// Other common noise claims
'sid', // Session ID (changes per session)
's_hash', // State hash
]);

const claimsToStore: Record<string, any> = {};

for (const [claimName, claimValue] of Object.entries(idTokenClaims)) {
if (!ignoreClaims.has(claimName) && claimValue !== null && claimValue !== undefined) {
claimsToStore[claimName] = claimValue;
}
}

// Add metadata
claimsToStore._provider = provider.provider;
claimsToStore._last_updated = new Date().toISOString();

// Convert to JSON and update the user property
const claimsJson = JSON.stringify(claimsToStore, null, 2);

try {
await kindeAPI.patch({
endpoint: `users/${userId}/properties`,
params: {
properties: {
[IDP_CLAIMS_PROPERTY_KEY]: claimsJson
}
},
});

console.log(`Successfully captured ${Object.keys(claimsToStore).filter(k => !k.startsWith('_')).length} IdP claims from ${provider.provider}`);
} catch (error: any) {
console.error("Error updating user properties:", error?.message || error);
throw error;
}
}