diff --git a/README.md b/README.md index c4a112bc6..387300529 100644 --- a/README.md +++ b/README.md @@ -251,7 +251,7 @@ const userPool = new UserPool(this, "UserPool", { ### External Identity Provider -This sample supports external identity provider. Currently we only support Google. To set up, See [SETUP_IDP.md](./docs/SETUP_IDP.md). +This sample supports external identity provider. Currently we support [Google](./docs/idp/SET_UP_GOOGLE.md) and [custom OIDC provider](./docs/idp/SET_UP_CUSTOM_OIDC.md). ### Encrypt Aurora Serverless storage diff --git a/cdk/lib/constructs/auth.ts b/cdk/lib/constructs/auth.ts index 8fc96e1a0..7f85bdff0 100644 --- a/cdk/lib/constructs/auth.ts +++ b/cdk/lib/constructs/auth.ts @@ -5,11 +5,12 @@ import { UserPoolClient, UserPoolIdentityProviderGoogle, CfnUserPoolGroup, + UserPoolIdentityProviderOidc, } from "aws-cdk-lib/aws-cognito"; import * as secretsmanager from "aws-cdk-lib/aws-secretsmanager"; import { Construct } from "constructs"; -import { Idp } from "../utils/identityProvider"; +import { Idp, TIdentityProvider } from "../utils/identityProvider"; export interface AuthProps { readonly origin: string; @@ -29,7 +30,8 @@ export class Auth extends Construct { requireDigits: true, minLength: 8, }, - selfSignUpEnabled: true, + // Disable if identity providers are configured + selfSignUpEnabled: !props.idp.isExist(), signInAliases: { username: false, email: true, @@ -59,43 +61,73 @@ export class Auth extends Construct { const client = userPool.addClient(`Client`, clientProps); - if (props.idp.isExist()) { - for (const provider of props.idp.getProviders()) { - switch (provider.service) { - case "google": { - const secret = secretsmanager.Secret.fromSecretNameV2( - this, - "Secret", - provider.secretName - ); + const configureProvider = ( + provider: TIdentityProvider, + userPool: UserPool, + client: UserPoolClient + ) => { + const secret = secretsmanager.Secret.fromSecretNameV2( + this, + "Secret", + provider.secretName + ); - const clientId = secret - .secretValueFromJson("clientId") - .unsafeUnwrap() - .toString(); - const clientSecret = secret.secretValueFromJson("clientSecret"); + const clientId = secret + .secretValueFromJson("clientId") + .unsafeUnwrap() + .toString(); + const clientSecret = secret.secretValueFromJson("clientSecret"); - const googleProvider = new UserPoolIdentityProviderGoogle( - this, - "GoogleProvider", - { - userPool, - clientId: clientId, - clientSecretValue: clientSecret, - scopes: ["openid", "email"], - attributeMapping: { - email: ProviderAttribute.GOOGLE_EMAIL, - }, - } - ); + switch (provider.service) { + // Currently only Google and custom OIDC are supported + case "google": { + const googleProvider = new UserPoolIdentityProviderGoogle( + this, + "GoogleProvider", + { + userPool, + clientId, + clientSecretValue: clientSecret, + scopes: ["openid", "email"], + attributeMapping: { + email: ProviderAttribute.GOOGLE_EMAIL, + }, + } + ); + client.node.addDependency(googleProvider); + } + case "oidc": { + const issuerUrl = secret + .secretValueFromJson("issuerUrl") + .unsafeUnwrap() + .toString(); - client.node.addDependency(googleProvider); - } - // set other providers - default: - continue; + const oidcProvider = new UserPoolIdentityProviderOidc( + this, + "OidcProvider", + { + name: provider.serviceName, + userPool, + clientId, + clientSecret: clientSecret.unsafeUnwrap().toString(), + issuerUrl, + attributeMapping: { + // This is an example of mapping the email attribute. + // Replace this with the actual idp attribute key. + email: ProviderAttribute.other("EMAIL"), + }, + scopes: ["openid", "email"], + } + ); + client.node.addDependency(oidcProvider); } } + }; + + if (props.idp.isExist()) { + for (const provider of props.idp.getProviders()) { + configureProvider(provider, userPool, client); + } userPool.addDomain("UserPool", { cognitoDomain: { diff --git a/cdk/lib/constructs/frontend.ts b/cdk/lib/constructs/frontend.ts index 5bf19a586..f42374895 100644 --- a/cdk/lib/constructs/frontend.ts +++ b/cdk/lib/constructs/frontend.ts @@ -112,6 +112,8 @@ export class Frontend extends Construct { VITE_APP_REDIRECT_SIGNOUT_URL: this.getOrigin(), VITE_APP_COGNITO_DOMAIN: cognitoDomain, VITE_APP_SOCIAL_PROVIDERS: idp.getSocialProviders(), + VITE_APP_CUSTOM_PROVIDER_ENABLED: idp.checkCustomProviderEnabled(), + VITE_APP_CUSTOM_PROVIDER_NAME: idp.getCustomProviderName(), }; return { ...defaultProps, ...oAuthProps }; })(); diff --git a/cdk/lib/utils/identityProvider.ts b/cdk/lib/utils/identityProvider.ts index 52afd9c45..ec787fbac 100644 --- a/cdk/lib/utils/identityProvider.ts +++ b/cdk/lib/utils/identityProvider.ts @@ -4,7 +4,17 @@ import { aws_cognito } from "aws-cdk-lib"; export type Idp = ReturnType; export type TIdentityProvider = { + /** + * Service name for social providers. + */ service: string; + /** + * Service name for OIDC. Required when service is "oidc" + */ + serviceName?: string; + /** + * Secret name of the secret in Secrets Manager. + */ secretName: string; }; @@ -39,8 +49,11 @@ export const identityProvider = (identityProviders: TIdentityProvider[]) => { }; const getSupportedIndetityProviders = () => { - return [...getProviders(), { service: "cognito" }].map(({ service }) => { - switch (service) { + return [ + ...getProviders(), + { service: "cognito", secretName: "" } as TIdentityProvider, + ].map((provider) => { + switch (provider.service) { case "google": return aws_cognito.UserPoolClientIdentityProvider.GOOGLE; case "facebook": @@ -51,22 +64,37 @@ export const identityProvider = (identityProviders: TIdentityProvider[]) => { return aws_cognito.UserPoolClientIdentityProvider.APPLE; case "cognito": return aws_cognito.UserPoolClientIdentityProvider.COGNITO; + case "oidc": + return aws_cognito.UserPoolClientIdentityProvider.custom( + provider.serviceName! // already validated + ); default: - throw new Error(`Invalid identity provider: ${service}`); + throw new Error(`Invalid identity provider: ${provider.service}`); } }); }; const getSocialProviders = () => getProviders() + .filter(({ service }) => service !== "oidc") .map(({ service }) => service) .join(","); + const checkCustomProviderEnabled = () => + // Currently only support OIDC provider (SAML not supported) + getProviders().some(({ service }) => service === "oidc"); + + const getCustomProviderName = () => + // Currently only support OIDC provider (SAML not supported) + getProviders().find(({ service }) => service === "oidc")?.serviceName; + return { isExist, getProviders, getSupportedIndetityProviders, getSocialProviders, + checkCustomProviderEnabled, + getCustomProviderName, }; }; @@ -77,12 +105,21 @@ const validateSocialProvider = ( provider: TIdentityProvider ): | Effect.Effect - | Effect.Effect => - !["google", "facebook", "amazon", "apple"].includes(provider.service) - ? Effect.fail({ - type: "InvalidSocialProvider", - }) - : Effect.succeed(provider); + | Effect.Effect => { + if ( + !["google", "facebook", "amazon", "apple", "oidc"].includes( + provider.service + ) + ) { + return Effect.fail({ type: "InvalidSocialProvider" }); + } + + if (provider.service === "oidc" && !provider.serviceName) { + return Effect.fail({ type: "InvalidSocialProvider" }); + } + + return Effect.succeed(provider); +}; const isIdpAsArray = ( identityProviders: TIdentityProvider[] diff --git a/cdk/test/cdk.test.ts b/cdk/test/cdk.test.ts index 57ae1ce91..7ee67b0db 100644 --- a/cdk/test/cdk.test.ts +++ b/cdk/test/cdk.test.ts @@ -52,6 +52,52 @@ describe("Fine-grained Assertions Test", () => { ); }); + test("Custom OIDC Provider Generation", () => { + const app = new cdk.App(); + const domainPrefix = "test-domain"; + const hasOidcProviderStack = new BedrockChatStack( + app, + "OidcProviderGenerateStack", + { + bedrockRegion: "us-east-1", + crossRegionReferences: true, + webAclId: "", + enableUsageAnalysis: true, + identityProviders: [ + { + secretName: "MyOidcTestSecret", + service: "oidc", + serviceName: "MyOidcProvider", + }, + ], + userPoolDomainPrefix: domainPrefix, + publishedApiAllowedIpV4AddressRanges: [""], + publishedApiAllowedIpV6AddressRanges: [""], + } + ); + const hasOidcProviderTemplate = Template.fromStack(hasOidcProviderStack); + + hasOidcProviderTemplate.hasResourceProperties( + "AWS::Cognito::UserPoolDomain", + { + Domain: domainPrefix, + } + ); + + hasOidcProviderTemplate.hasResourceProperties( + "AWS::Cognito::UserPoolClient", + { + SupportedIdentityProviders: ["MyOidcProvider", "COGNITO"], + } + ); + hasOidcProviderTemplate.hasResourceProperties( + "AWS::Cognito::UserPoolIdentityProvider", + { + ProviderType: "OIDC", + } + ); + }); + test("default stack", () => { const app = new cdk.App(); diff --git a/docs/README_ja.md b/docs/README_ja.md index a07b5eef1..6e715a220 100644 --- a/docs/README_ja.md +++ b/docs/README_ja.md @@ -241,7 +241,7 @@ const userPool = new UserPool(this, "UserPool", { ### 外部のアイデンティティプロバイダー -このサンプルは外部のアイデンティティプロバイダーをサポートしています。現在、Google のみをサポートしています。設定するには、[こちら](./SET_UP_IDP_ja.md)をご覧ください。 +このサンプルは外部のアイデンティティプロバイダーをサポートしています。現在、[Google](./idp/SET_UP_GOOGLE_ja.md)および[カスタム OIDC プロバイダー](./idp/SET_UP_CUSTOM_OIDC.md)をサポートしています。 ### ローカルでの開発について diff --git a/docs/idp/SET_UP_CUSTOM_OIDC.md b/docs/idp/SET_UP_CUSTOM_OIDC.md new file mode 100644 index 000000000..81f4791bc --- /dev/null +++ b/docs/idp/SET_UP_CUSTOM_OIDC.md @@ -0,0 +1,63 @@ +# Set up external identity provider + +## Step 1: Create an OIDC Client + +Follow the procedures for the target OIDC provider, and note the values for the OIDC client ID and secret. Also issuer URL is required on the following steps. If redirect URI is needed for the setup process, enter dummy value, which will be replaced after deployment completed. + +## Step 2: Store Credentials in AWS Secrets Manager + +1. Go to the AWS Management Console. +2. Navigate to Secrets Manager and choose "Store a new secret". +3. Select "Other type of secrets". +4. Input the client ID and client secret as key-value pairs. + + - Key: `clientId`, Value: + - Key: `clientSecret`, Value: + - Key: `issuerUrl`, Value: + +5. Follow the prompts to name and describe the secret. Note the secret name as you will need it in your CDK code (Used in Step 3 variable name ). +6. Review and store the secret. + +### Attention + +The key names must exactly match the strings `clientId`, `clientSecret` and `issuerUrl`. + +## Step 3: Update cdk.json + +In your cdk.json file, add the ID Provider and SecretName to the cdk.json file. + +like so: + +```json +{ + "context": { + // ... + "identityProviders": [ + { + "service": "oidc", // Do not change + "serviceName": "", // Set any value you like + "secretName": "" + } + ], + "userPoolDomainPrefix": "" + } +} +``` + +### Attention + +#### Uniqueness + +The `userPoolDomainPrefix` must be globally unique across all Amazon Cognito users. If you choose a prefix that's already in use by another AWS account, the creation of the user pool domain will fail. It's a good practice to include identifiers, project names, or environment names in the prefix to ensure uniqueness. + +## Step 4: Deploy Your CDK Stack + +Deploy your CDK stack to AWS: + +```sh +cdk deploy --require-approval never --all +``` + +## Step 5: Update OIDC Client with Cognito Redirect URIs + +After deploying the stack, `AuthApprovedRedirectURI` is showing on the CloudFormation outputs. Go back to your OIDC configuration and update with the correct redirect URIs. diff --git a/docs/SETUP_IDP.md b/docs/idp/SET_UP_GOOGLE.md similarity index 96% rename from docs/SETUP_IDP.md rename to docs/idp/SET_UP_GOOGLE.md index ef7ff3bac..1039be965 100644 --- a/docs/SETUP_IDP.md +++ b/docs/idp/SET_UP_GOOGLE.md @@ -1,6 +1,4 @@ -# Set up external identity provider - -Currently we support Google for external Idp. +# Set up external identity provider for Google ## Step 1: Create a Google OAuth 2.0 Client diff --git a/docs/SET_UP_IDP_ja.md b/docs/idp/SET_UP_GOOGLE_ja.md similarity index 96% rename from docs/SET_UP_IDP_ja.md rename to docs/idp/SET_UP_GOOGLE_ja.md index 52f216414..9628d7a22 100644 --- a/docs/SET_UP_IDP_ja.md +++ b/docs/idp/SET_UP_GOOGLE_ja.md @@ -1,6 +1,4 @@ -# 外部アイデンティティプロバイダーの設定 - -現在、外部 Idp として Google をサポートしています。 +# 外部アイデンティティプロバイダー (Google) の設定 ## ステップ 1: Google OAuth 2.0 クライアントを作成する diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index bcad9bc4d..619ba927c 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,20 +1,20 @@ -import React, { useCallback, useEffect } from 'react'; -import { PiList, PiPlus } from 'react-icons/pi'; -import { Outlet, useNavigate, useParams } from 'react-router-dom'; -import ChatListDrawer from './components/ChatListDrawer'; -import { Authenticator, translations } from '@aws-amplify/ui-react'; +import React, { useEffect } from 'react'; +import { translations } from '@aws-amplify/ui-react'; import { Amplify, I18n } from 'aws-amplify'; import '@aws-amplify/ui-react/styles.css'; -import useDrawer from './hooks/useDrawer'; -import ButtonIcon from './components/ButtonIcon'; - -import useConversation from './hooks/useConversation'; -import LazyOutputText from './components/LazyOutputText'; -import useChat from './hooks/useChat'; -import SnackbarProvider from './providers/SnackbarProvider'; +import AuthAmplify from './components/AuthAmplify'; +import AuthCustom from './components/AuthCustom'; +import { Authenticator } from '@aws-amplify/ui-react'; import { useTranslation } from 'react-i18next'; import './i18n'; import { validateSocialProvider } from './utils/SocialProviderUtils'; +import AppContent from './components/AppContent'; + +const customProviderEnabled = + import.meta.env.VITE_APP_CUSTOM_PROVIDER_ENABLED === 'true'; +const socialProviderFromEnv = import.meta.env.VITE_APP_SOCIAL_PROVIDERS?.split( + ',' +).filter(validateSocialProvider); const App: React.FC = () => { const { t, i18n } = useTranslation(); @@ -32,7 +32,7 @@ const App: React.FC = () => { authenticationFlowType: 'USER_SRP_AUTH', oauth: { domain: import.meta.env.VITE_APP_COGNITO_DOMAIN, - scope: ['openid'], + scope: ['openid', 'email'], redirectSignIn: import.meta.env.VITE_APP_REDIRECT_SIGNIN_URL, redirectSignOut: import.meta.env.VITE_APP_REDIRECT_SIGNOUT_URL, responseType: 'code', @@ -43,77 +43,20 @@ const App: React.FC = () => { I18n.putVocabularies(translations); I18n.setLanguage(i18n.language); - const { switchOpen: switchDrawer } = useDrawer(); - const navigate = useNavigate(); - - const { conversationId } = useParams(); - const { getTitle } = useConversation(); - const { isGeneratedTitle } = useChat(); - - const onClickNewChat = useCallback(() => { - navigate('/'); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - - const socialProviderFromEnv = - import.meta.env.VITE_APP_SOCIAL_PROVIDERS?.split(',').filter( - validateSocialProvider - ); - return ( - ( -
- {t('app.name')} -
- ), - }}> - {({ signOut }) => ( -
- { - signOut ? signOut() : null; - }} - /> - -
-
- - -
- {isGeneratedTitle ? ( - <> - - - ) : ( - <>{getTitle(conversationId ?? '')} - )} -
- - - - -
- -
- - - -
-
-
+ <> + {customProviderEnabled ? ( + + + + ) : ( + + + + + )} -
+ ); }; diff --git a/frontend/src/components/AppContent.tsx b/frontend/src/components/AppContent.tsx new file mode 100644 index 000000000..ec357d172 --- /dev/null +++ b/frontend/src/components/AppContent.tsx @@ -0,0 +1,75 @@ +import React, { useCallback } from 'react'; +import ChatListDrawer from './ChatListDrawer'; +import { BaseProps } from '../@types/common'; +import LazyOutputText from './LazyOutputText'; +import { PiList, PiPlus } from 'react-icons/pi'; +import ButtonIcon from './ButtonIcon'; +import SnackbarProvider from '../providers/SnackbarProvider'; +import { Outlet } from 'react-router-dom'; +import { useNavigate, useParams } from 'react-router-dom'; +import useDrawer from '../hooks/useDrawer'; +import useConversation from '../hooks/useConversation'; +import useChat from '../hooks/useChat'; + +type Props = BaseProps & { + signOut?: () => void; +}; + +const AppContent: React.FC = (props) => { + const { switchOpen: switchDrawer } = useDrawer(); + const navigate = useNavigate(); + const { conversationId } = useParams(); + const { getTitle } = useConversation(); + const { isGeneratedTitle } = useChat(); + + const onClickNewChat = useCallback(() => { + navigate('/'); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + return ( +
+ { + props.signOut ? props.signOut() : null; + }} + /> + +
+
+ + +
+ {isGeneratedTitle ? ( + <> + + + ) : ( + <>{getTitle(conversationId ?? '')} + )} +
+ + + + +
+ +
+ + + +
+
+
+ ); +}; + +export default AppContent; diff --git a/frontend/src/components/AuthAmplify.tsx b/frontend/src/components/AuthAmplify.tsx new file mode 100644 index 000000000..1bbc5f81a --- /dev/null +++ b/frontend/src/components/AuthAmplify.tsx @@ -0,0 +1,31 @@ +import React, { ReactNode, cloneElement, ReactElement } from 'react'; +import { BaseProps } from '../@types/common'; +import { Authenticator } from '@aws-amplify/ui-react'; +import { SocialProvider } from '@aws-amplify/ui'; +import { useTranslation } from 'react-i18next'; +import { useAuthenticator } from '@aws-amplify/ui-react'; + +type Props = BaseProps & { + socialProviders: SocialProvider[]; + children: ReactNode; +}; + +const AuthAmplify: React.FC = ({ socialProviders, children }) => { + const { t } = useTranslation(); + const { signOut } = useAuthenticator(); + return ( + ( +
+ {t('app.name')} +
+ ), + }}> + <>{cloneElement(children as ReactElement, { signOut })} +
+ ); +}; + +export default AuthAmplify; diff --git a/frontend/src/components/AuthCustom.tsx b/frontend/src/components/AuthCustom.tsx new file mode 100644 index 000000000..007111725 --- /dev/null +++ b/frontend/src/components/AuthCustom.tsx @@ -0,0 +1,72 @@ +import React, { + ReactNode, + useState, + useEffect, + cloneElement, + ReactElement, +} from 'react'; +import Button from './Button'; +import { BaseProps } from '../@types/common'; +import { Auth } from 'aws-amplify'; +import { useTranslation } from 'react-i18next'; +import { PiCircleNotch } from 'react-icons/pi'; + +type Props = BaseProps & { + children: ReactNode; +}; + +const AuthCustom: React.FC = ({ children }) => { + const [authenticated, setAuthenticated] = useState(false); + const [loading, setLoading] = useState(true); + const { t } = useTranslation(); + + useEffect(() => { + Auth.currentAuthenticatedUser() + .then(() => { + setAuthenticated(true); + }) + .catch(() => { + setAuthenticated(false); + }) + .finally(() => { + setLoading(false); + }); + }, []); + + const signIn = () => { + Auth.federatedSignIn({ + customProvider: import.meta.env.VITE_APP_CUSTOM_PROVIDER_NAME, + }); + }; + + const signOut = () => { + Auth.signOut(); + }; + + return ( + <> + {loading ? ( +
+
Loading...
+
+ +
+
+ ) : !authenticated ? ( +
+
+ {t('app.name')} +
+ +
+ ) : ( + // Pass the signOut function to the child component + <>{cloneElement(children as ReactElement, { signOut })} + )} + + ); +}; + +export default AuthCustom; diff --git a/frontend/src/i18n/en/index.ts b/frontend/src/i18n/en/index.ts index 10b1c377e..412f5407c 100644 --- a/frontend/src/i18n/en/index.ts +++ b/frontend/src/i18n/en/index.ts @@ -1,5 +1,10 @@ const translation = { translation: { + signIn: { + button: { + login: 'Login', + }, + }, app: { name: 'Bedrock Claude Chat', inputMessage: 'Send a message', diff --git a/frontend/src/i18n/ja/index.ts b/frontend/src/i18n/ja/index.ts index d663a0f67..8c94fd35d 100644 --- a/frontend/src/i18n/ja/index.ts +++ b/frontend/src/i18n/ja/index.ts @@ -3,6 +3,11 @@ // const translation: typeof en = { const translation = { translation: { + signIn: { + button: { + login: 'ログイン', + }, + }, app: { name: 'Bedrock Claude Chat', inputMessage: '入力してください', diff --git a/frontend/src/vite-env.d.ts b/frontend/src/vite-env.d.ts index 250658355..06fcf2699 100644 --- a/frontend/src/vite-env.d.ts +++ b/frontend/src/vite-env.d.ts @@ -10,6 +10,8 @@ interface ImportMetaEnv { readonly VITE_APP_REDIRECT_SIGNIN_URL: string; readonly VITE_APP_REDIRECT_SIGNOUT_URL: string; readonly VITE_APP_SOCIAL_PROVIDERS: string; + readonly VITE_APP_CUSTOM_PROVIDER_ENABLED: string; + readonly VITE_APP_CUSTOM_PROVIDER_NAME: string; } interface ImportMeta {