Skip to content

feat: Add external SAML/OIDC identity provider federation with Cognito#254

Merged
rstrahan merged 3 commits intoaws-solutions-library-samples:developfrom
rcgeorge:feature/externalidp
Apr 7, 2026
Merged

feat: Add external SAML/OIDC identity provider federation with Cognito#254
rstrahan merged 3 commits intoaws-solutions-library-samples:developfrom
rcgeorge:feature/externalidp

Conversation

@rcgeorge
Copy link
Copy Markdown
Contributor

Adds optional support for federating authentication through an external SAML or OIDC identity provider via Amazon Cognito. This enables organizations to use their existing enterprise identity providers (PingOne, Okta, Microsoft Entra ID, etc.) for single sign-on to the GenAI IDP solution.

All federation functionality is opt-in through CloudFormation parameters — leaving them empty results in zero additional resources and identical behavior to the existing Cognito-native authentication.

What's included

Infrastructure (template.yaml)

  • 12 new optional CloudFormation parameters for IdP configuration, group mapping, and auto-login
  • Conditional AWS::Cognito::UserPoolIdentityProvider resource supporting both SAML and OIDC
  • PreTokenGeneration Lambda trigger that maps external IdP group claims to Cognito groups (Admin, Author, Reviewer, Viewer)
  • custom:idp_groups attribute on the UserPool schema for receiving group claims
  • Conditional SupportedIdentityProviders, LogoutURLs, and CallbackURLs on the UserPoolClient
  • WaitConditionHandle pattern for conditional resource dependency ordering

Frontend (src/ui)

  • "Sign in with [IdP Name]" button on the login page (conditionally rendered)
  • Optional auto-login redirect that bypasses the Cognito hosted UI
  • Federated logout that clears Amplify tokens and redirects through Cognito's /logout endpoint
  • Token refresh on first federated login to resolve group mapping timing
  • OAuth configuration in aws-exports.js conditionally enabled when an external IdP is configured

Documentation (docs/external-idp.md)

  • Step-by-step setup guides for PingOne (SAML and OIDC), Okta (SAML and OIDC), and Microsoft Entra ID (SAML and OIDC)
  • Parameter reference, troubleshooting table, and auto-login behavior notes

Testing

  • Deployed with Cognito-only (no federation) — verified no behavioral changes
  • Deployed with PingOne SAML — verified sign-in, group mapping, sign-out, and toggle on/off
  • Deployed with PingOne OIDC — verified authentication flow
  • Verified group mapping works on first login via token refresh
  • Verified federated logout clears both Cognito and local sessions
  • cfn-lint, ruff, prettier, ARN partition check all pass
  • ASH security scan: zero findings related to federation changes

By submitting this pull request, I confirm that you can use, modify, copy, and redistribute this contribution, under the terms of your choice.

- Add optional CloudFormation parameters for SAML and OIDC federation
- Support PingOne, Okta, and Microsoft Entra ID as external IdPs
- Add PreTokenGeneration Lambda for automatic IdP group to Cognito group mapping
- Add federated sign-in button to the web UI login page
- Add auto-login option to bypass Cognito hosted UI
- Add federated logout through Cognito logout endpoint
- Update UserPoolClient with conditional SupportedIdentityProviders and LogoutURLs
- Add custom:idp_groups attribute to UserPool schema for group claim mapping
- Add comprehensive documentation in docs/external-idp.md
- Update aws-exports.js to conditionally configure OAuth for federation
@rstrahan
Copy link
Copy Markdown
Contributor

rstrahan commented Apr 2, 2026

This is a very awesome PR, @rcgeorge - Thanks!!

Some issues to address before merging

Resolve merge conflicts

Address items in Claude's review below:

  1. IAM Policy Too Broad — Uses userpool/* Wildcard

Resource: !Sub "arn:${AWS::Partition}:cognito-idp:${AWS::Region}:${AWS::AccountId}:userpool/*"
This grants the Lambda access to all user pools in the account, not just the one in this stack. It should be scoped to the specific user pool:

Resource: !Sub "arn:${AWS::Partition}:cognito-idp:${AWS::Region}:${AWS::AccountId}:userpool/${UserPool}"
  1. LambdaConfig Modification Has a Structural Bug
    The current diff modifies the LambdaConfig like this:
LambdaConfig: !If
  - ShouldAllowSignUpEmailDomain
  - PreAuthentication: !GetAtt CognitoUserPoolEmailDomainVerifyFunction.Arn
    PreSignUp: !GetAtt CognitoUserPoolEmailDomainVerifyFunction.Arn
    PreTokenGeneration: !If
      - ShouldMapExternalIdPGroups
      - !GetAtt ExternalIdPGroupMappingFunction.Arn
      - !Ref AWS::NoValue
  - !If
    - ShouldMapExternalIdPGroups
    - PreTokenGeneration: !GetAtt ExternalIdPGroupMappingFunction.Arn
    - !Ref AWS::NoValue

This is correct in logic (handles both branches: with and without email domain verification), and the nested !If is valid CloudFormation. However, this is a subtle 3-way conditional that should have a YAML comment explaining the matrix of ShouldAllowSignUpEmailDomain × ShouldMapExternalIdPGroups combinations for future maintainers.

  1. OIDC Client Secret Stored in Plain Text in CloudFormation Parameters
    While NoEcho: true hides the value in the console, the secret is still stored in the CloudFormation template parameters and visible via describe-stacks API. For production use, this should ideally reference a Secrets Manager secret or SSM SecureString parameter. At minimum, the documentation should warn about this limitation.

  2. Frontend: Hardcoded Inline Styles
    The FederatedSignInButton uses hardcoded inline styles:

backgroundColor: '#0073bb',
borderRadius: '4px',

The rest of the UI uses Cloudscape Design System components. This button should use a Cloudscape Button component for visual consistency:

<Button variant="primary" onClick={() => signInWithRedirect({ provider: { custom: VITE_EXTERNAL_IDP_NAME } })}>
  Sign in with {VITE_EXTERNAL_IDP_NAME}
</Button>
  1. Manual localStorage Cleanup in Sign-Out is Fragile
const keysToRemove = Object.keys(localStorage).filter(
  (k) => k.startsWith('CognitoIdentityServiceProvider') || k.startsWith('amplify'),
);
keysToRemove.forEach((k) => localStorage.removeItem(k));

This hardcodes Amplify's internal storage key prefixes, which could change in future Amplify versions. Consider using Amplify's signOut({ global: true }) with the oauth config instead, or at least add a comment noting this coupling.

  1. Module-Level autoLoginAttempted Flag is a Memory Leak Pattern
let autoLoginAttempted = false;

This module-level mutable variable persists across route navigations within the same SPA session. If the auto-login redirect fails silently (e.g., network issue), the user gets stuck seeing the login form with no retry mechanism. Consider using sessionStorage (like the idp_signed_out flag already does) for consistency and to survive page refreshes.

  1. Token Refresh Race Condition in use-user-role.ts
const isFederated = (session?.tokens?.idToken?.payload?.['identities'] as string | undefined) !== undefined;
const appGroups = groupsArray.filter((g) => ['Admin', 'Author', 'Reviewer', 'Viewer'].includes(g));
if (isFederated && appGroups.length === 0) {
  const refreshed = await fetchAuthSession({ forceRefresh: true });

This only retries once. If the PreTokenGeneration Lambda hasn't completed group assignment by the time the refresh happens, the user still gets no groups. Consider a brief delay or retry with backoff. Also, this forceRefresh fires on every render cycle where groups are empty, since useEffect re-runs — ensure that's not causing excessive token refresh calls.

  1. Outputs Section Leaks Values When Federation is Not Configured
VITE_COGNITO_DOMAIN=${GetDomain.OutputString}.auth.${AWS::Region}.amazoncognito.com
VITE_EXTERNAL_IDP_NAME=${ExternalIdPName}
VITE_EXTERNAL_IDP_AUTO_LOGIN=${ExternalIdPAutoLogin}

In the Outputs section, these values are always emitted even when federation is not configured (unlike the CodeBuild env vars which use !If). When ExternalIdPName is empty, this emits VITE_EXTERNAL_IDP_NAME= which is fine functionally but inconsistent with the !If pattern used in the CodeBuild section.

🟢 Low / Nit
9. authorize_scopes Includes groups Which is Non-Standard

authorize_scopes: "openid email profile groups"

The groups scope is not part of the OIDC standard and not all providers support it. This could cause auth errors with some IdPs. Consider making this configurable or documenting that it may need adjustment.

  1. Missing VITE_EXTERNAL_IDP_AUTO_LOGIN in aws-exports.js Destructuring
    The PR adds VITE_COGNITO_DOMAIN and VITE_EXTERNAL_IDP_NAME to the destructured import.meta.env in aws-exports.js, but VITE_EXTERNAL_IDP_AUTO_LOGIN is read in UnauthRoutes.tsx directly via import.meta.env.VITE_EXTERNAL_IDP_AUTO_LOGIN. This inconsistency is minor but worth normalizing.

  2. VITE_CLOUDFRONT_DOMAIN Added to Destructuring But Not New
    The diff shows VITE_CLOUDFRONT_DOMAIN added to the destructured variables in aws-exports.js, but it's used in the new oauthConfig. It should already exist in the env — just confirm it's always available (it may be empty for ALB hosting).

  3. No Unit Tests for the Lambda Function
    The inline Lambda has no unit tests. While it's a simple function, the group parsing logic (JSON array, comma-separated, single value) and the bidirectional group sync logic would benefit from tests, especially the edge cases.

Overall this is a high-quality PR with excellent documentation and strong backward compatibility. The architecture is well-thought-out. After fixing the IAM wildcard and the medium-severity UX issues, it would be ready to merge.

@rstrahan rstrahan marked this pull request as draft April 2, 2026 22:44
rcgeorge added 2 commits April 7, 2026 11:46
- Scope IAM policy to specific UserPool (break circular dep with standalone IAM::Policy)
- Add LambdaConfig condition matrix comment
- Move OIDC client secret to Secrets Manager via Custom Resource Lambda
- Replace inline styles with Cloudscape Button component
- Replace manual localStorage cleanup with Amplify signOut()
- Switch autoLoginAttempted to sessionStorage
- Add try/catch to federated token refresh
- Make Outputs VITE_ vars conditional with !Join/!If
- Remove non-standard groups OIDC scope
- Add VITE_EXTERNAL_IDP_AUTO_LOGIN to aws-exports.js destructuring
- Fix whitespace-only idp_groups edge case in group mapping Lambda
- Add 26 unit tests for ExternalIdPGroupMappingFunction
- Add OIDC client secret security docs
@rcgeorge
Copy link
Copy Markdown
Contributor Author

rcgeorge commented Apr 7, 2026

Thanks for the thorough review! All items have been addressed and the branch has been tested with a PingOne SAML deployment. Merge conflicts with upstream develop are clean.

Here's what was done for each item:

# Issue Resolution
1 IAM Policy Too Broad (userpool/*) Scoped to specific UserPool via a standalone AWS::IAM::Policy resource, avoiding the circular dependency (UserPool → LambdaConfig → Function → Role → UserPool)
2 LambdaConfig Structural Bug Added YAML comment documenting the 2×2 condition matrix (EmailDomain × GroupMapping)
3 OIDC Client Secret in Plain Text Replaced ExternalIdPOIDCClientSecret parameter with ExternalIdPOIDCClientSecretArn. Added a Custom Resource Lambda that reads the secret from Secrets Manager at deploy time — the secret value never enters CloudFormation parameters. Docs updated with setup instructions.
4 Hardcoded Inline Styles Replaced with Cloudscape Button component
5 Manual localStorage Cleanup Removed entirely — replaced with Amplify's signOut() which handles both federated and non-federated flows using the redirectSignOut already configured in aws-exports.js
6 Module-Level autoLoginAttempted Flag Switched to sessionStorage, consistent with the existing idp_signed_out pattern
7 Token Refresh Race Condition Added try/catch guard + clarifying comment. The useEffect has [] deps so it only fires once on mount — no excessive refresh calls.
8 Outputs Leak Values Converted WebUITestEnvFile from !Sub to !Join with !If conditions, matching the CodeBuild section's conditional pattern
9 Non-Standard groups Scope Removed. All three major IdPs (PingOne, Okta, Entra ID) deliver group claims through IdP-side configuration, not via OIDC scopes.
10 Missing VITE_EXTERNAL_IDP_AUTO_LOGIN Added to aws-exports.js destructuring
11 VITE_CLOUDFRONT_DOMAIN Availability Confirmed always set — resolves to CloudFront URL or ALB URL depending on hosting mode
12 No Lambda Unit Tests Added 26 pytest tests covering group parsing (JSON array, comma-separated, single value, edge cases), bidirectional group sync, error handling, and token override injection. Tests also caught and fixed a whitespace-only custom:idp_groups edge case bug.

Bonus fix: The whitespace-only edge case in the group parsing logic — " " as custom:idp_groups would have produced [''] instead of being treated as empty. Fixed in both the inline Lambda and the extracted test module.

Tested: Deployed with PingOne SAML federation to an existing stack in us-east-1. Federation sign-in, group mapping, sign-out, and auto-login flag all working as expected.

@rstrahan
Copy link
Copy Markdown
Contributor

rstrahan commented Apr 7, 2026

Awesome @rcgeorge - approved and ready to merge..

@rstrahan rstrahan marked this pull request as ready for review April 7, 2026 19:23
@rstrahan rstrahan merged commit d369666 into aws-solutions-library-samples:develop Apr 7, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants