From 88701bf40370fb01a0a48fe9c0f7f588acfea19a Mon Sep 17 00:00:00 2001 From: Koosha Owji Date: Wed, 5 Nov 2025 15:26:04 +1100 Subject: [PATCH 1/3] Create IdpTokenWorkflow.ts --- postUserAuthentication/IdpTokenWorkflow.ts | 94 ++++++++++++++++++++++ 1 file changed, 94 insertions(+) create mode 100644 postUserAuthentication/IdpTokenWorkflow.ts diff --git a/postUserAuthentication/IdpTokenWorkflow.ts b/postUserAuthentication/IdpTokenWorkflow.ts new file mode 100644 index 0000000..57953f3 --- /dev/null +++ b/postUserAuthentication/IdpTokenWorkflow.ts @@ -0,0 +1,94 @@ +import { + onPostAuthenticationEvent, + WorkflowSettings, + WorkflowTrigger, + accessTokenCustomClaims, + idTokenCustomClaims, +} from "@kinde/infrastructure"; + +// This workflow extracts claims from social identity provider (IdP) tokens and adds them +// as custom claims to Kinde's access and ID tokens. This allows you to preserve additional +// user information from the social provider that may not be captured by Kinde by default. +// +// IMPORTANT: This is a simplified example that extracts only the email claim to demonstrate +// the pattern. You can easily extend this to extract additional claims such as name, picture, +// email_verified, locale, or provider-specific claims (e.g., Google Workspace domain). +// See the comments in the code for available claims you can extract. +// +// This workflow supports OAuth2 / OpenID Connect (OIDC) providers such as: +// * Google +// * Microsoft / Azure AD +// * Any OIDC-compliant provider +// +// Note: Pure OAuth 2.0 providers (like GitHub) that do not issue JWT ID tokens will not +// have claims available in the provider.data.idToken object. +// +// Setup steps: +// +// 1. Configure your social connection in Kinde (e.g., Google, Microsoft). +// +// 2. This workflow will automatically extract claims from the IdP's ID token during authentication. +// +// 3. The following claims are commonly available from OIDC providers: +// * sub - The user's unique identifier at the IdP +// * email - The user's email address +// * name - The user's full name +// * picture - URL to the user's profile picture +// * email_verified - Whether the email has been verified by the IdP +// * given_name / family_name - First and last name +// * locale - User's preferred language/locale +// +// 4. Provider-specific claims may also be available: +// * Google: hd (hosted domain for Google Workspace users) +// * Microsoft: tid (tenant ID for Azure AD users) +// +// Once configured, this workflow will run after a user authenticates via a social connection, +// and the custom claims will be included in the tokens returned to your application. + +export const workflowSettings: WorkflowSettings = { + id: "postAuthentication", + name: "IdpTokenWorkflow", + trigger: WorkflowTrigger.PostAuthentication, + failurePolicy: { + action: "stop", + }, + bindings: { + "kinde.accessToken": {}, // Required to modify access token claims + "kinde.idToken": {}, // Required to modify ID token claims + }, +}; + +export default async function handlePostAuth(event: onPostAuthenticationEvent) { + const provider = event.context?.auth?.provider; + + // Only process OAuth2/OIDC social connections + if (!provider || provider.protocol !== "oauth2") { + return; + } + + const idTokenClaims = provider.data?.idToken?.claims; + + // If no ID token claims are available, skip processing + // This is expected for pure OAuth 2.0 providers like GitHub + if (!idTokenClaims) { + return; + } + + // Set the types for the custom claims we want to add + const accessToken = accessTokenCustomClaims<{ + idp_email: string; + }>(); + + // Add the user's email from the IdP to the access token + if (idTokenClaims.email) { + accessToken.idp_email = idTokenClaims.email as string; + } + + // You can also extract other claims from the IdP token: + // * idTokenClaims.sub - User's unique ID at the IdP + // * idTokenClaims.name - User's full name + // * idTokenClaims.picture - Profile picture URL + // * idTokenClaims.email_verified - Email verification status + // * idTokenClaims.hd - Google Workspace hosted domain + // * idTokenClaims.tid - Microsoft tenant ID +} From 16d671fd4cc8b6532a3665bac21520cd7c1d5d41 Mon Sep 17 00:00:00 2001 From: Koosha Owji Date: Fri, 7 Nov 2025 20:35:03 +1100 Subject: [PATCH 2/3] feat: add OAuth2 IdP claims to Kinde tokens workflow --- .../AddIdpClaimsToTokensWorkflow.ts | 144 +++++++++++++ .../CaptureIdpClaimsWorkflow.ts | 195 ++++++++++++++++++ postUserAuthentication/IdpTokenWorkflow.ts | 94 --------- 3 files changed, 339 insertions(+), 94 deletions(-) create mode 100644 captureIdpClaimsToTokens/AddIdpClaimsToTokensWorkflow.ts create mode 100644 captureIdpClaimsToTokens/CaptureIdpClaimsWorkflow.ts delete mode 100644 postUserAuthentication/IdpTokenWorkflow.ts 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; + } +} + diff --git a/postUserAuthentication/IdpTokenWorkflow.ts b/postUserAuthentication/IdpTokenWorkflow.ts deleted file mode 100644 index 57953f3..0000000 --- a/postUserAuthentication/IdpTokenWorkflow.ts +++ /dev/null @@ -1,94 +0,0 @@ -import { - onPostAuthenticationEvent, - WorkflowSettings, - WorkflowTrigger, - accessTokenCustomClaims, - idTokenCustomClaims, -} from "@kinde/infrastructure"; - -// This workflow extracts claims from social identity provider (IdP) tokens and adds them -// as custom claims to Kinde's access and ID tokens. This allows you to preserve additional -// user information from the social provider that may not be captured by Kinde by default. -// -// IMPORTANT: This is a simplified example that extracts only the email claim to demonstrate -// the pattern. You can easily extend this to extract additional claims such as name, picture, -// email_verified, locale, or provider-specific claims (e.g., Google Workspace domain). -// See the comments in the code for available claims you can extract. -// -// This workflow supports OAuth2 / OpenID Connect (OIDC) providers such as: -// * Google -// * Microsoft / Azure AD -// * Any OIDC-compliant provider -// -// Note: Pure OAuth 2.0 providers (like GitHub) that do not issue JWT ID tokens will not -// have claims available in the provider.data.idToken object. -// -// Setup steps: -// -// 1. Configure your social connection in Kinde (e.g., Google, Microsoft). -// -// 2. This workflow will automatically extract claims from the IdP's ID token during authentication. -// -// 3. The following claims are commonly available from OIDC providers: -// * sub - The user's unique identifier at the IdP -// * email - The user's email address -// * name - The user's full name -// * picture - URL to the user's profile picture -// * email_verified - Whether the email has been verified by the IdP -// * given_name / family_name - First and last name -// * locale - User's preferred language/locale -// -// 4. Provider-specific claims may also be available: -// * Google: hd (hosted domain for Google Workspace users) -// * Microsoft: tid (tenant ID for Azure AD users) -// -// Once configured, this workflow will run after a user authenticates via a social connection, -// and the custom claims will be included in the tokens returned to your application. - -export const workflowSettings: WorkflowSettings = { - id: "postAuthentication", - name: "IdpTokenWorkflow", - trigger: WorkflowTrigger.PostAuthentication, - failurePolicy: { - action: "stop", - }, - bindings: { - "kinde.accessToken": {}, // Required to modify access token claims - "kinde.idToken": {}, // Required to modify ID token claims - }, -}; - -export default async function handlePostAuth(event: onPostAuthenticationEvent) { - const provider = event.context?.auth?.provider; - - // Only process OAuth2/OIDC social connections - if (!provider || provider.protocol !== "oauth2") { - return; - } - - const idTokenClaims = provider.data?.idToken?.claims; - - // If no ID token claims are available, skip processing - // This is expected for pure OAuth 2.0 providers like GitHub - if (!idTokenClaims) { - return; - } - - // Set the types for the custom claims we want to add - const accessToken = accessTokenCustomClaims<{ - idp_email: string; - }>(); - - // Add the user's email from the IdP to the access token - if (idTokenClaims.email) { - accessToken.idp_email = idTokenClaims.email as string; - } - - // You can also extract other claims from the IdP token: - // * idTokenClaims.sub - User's unique ID at the IdP - // * idTokenClaims.name - User's full name - // * idTokenClaims.picture - Profile picture URL - // * idTokenClaims.email_verified - Email verification status - // * idTokenClaims.hd - Google Workspace hosted domain - // * idTokenClaims.tid - Microsoft tenant ID -} From 50552a3fcddf431a9e0075114c2d08db28e54b77 Mon Sep 17 00:00:00 2001 From: Koosha Owji Date: Wed, 19 Nov 2025 09:29:06 +1100 Subject: [PATCH 3/3] chore: update readme with new workflow example --- README.md | 2 ++ 1 file changed, 2 insertions(+) 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