diff --git a/README.md b/README.md index 308374d..10a3529 100644 --- a/README.md +++ b/README.md @@ -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 | @@ -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 diff --git a/captureIdpClaimsToTokens/AddIdpClaimsToTokensWorkflow.ts b/captureIdpClaimsToTokens/AddIdpClaimsToTokensWorkflow.ts new file mode 100644 index 0000000..8feb63a --- /dev/null +++ b/captureIdpClaimsToTokens/AddIdpClaimsToTokensWorkflow.ts @@ -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 = {}; + + 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; + 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>(); + const idToken = idTokenCustomClaims>(); + + // 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'})`); + } +} + diff --git a/captureIdpClaimsToTokens/CaptureIdpClaimsWorkflow.ts b/captureIdpClaimsToTokens/CaptureIdpClaimsWorkflow.ts new file mode 100644 index 0000000..4d63755 --- /dev/null +++ b/captureIdpClaimsToTokens/CaptureIdpClaimsWorkflow.ts @@ -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; + } + } + + // 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 = {}; + + 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; + } +} +