CognitoマネージドログインのカスタムスタイルをCDKやCFnで管理するのうえでの課題

## Cognitoマネージドログインとは
これまでCognitoの標準のログイン画面（Hosted UI）は非常に簡素な画面でしたが，2024年11月にマネージドログインがリリースされ，ログインUIを柔軟にカスタマイズできるようになり，リッチなUIを仕上げられるようにました．

https://aws.amazon.com/jp/about-aws/whats-new/2024/11/amazon-cognito-managed-login/

▼ これまでの Hosted UI
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/366359/14a41a6e-a818-f4d8-cae7-2d90fcb5e4d2.png)

▼ マネージドログイン（デフォルト状態）
![スクリーンショット 2024-12-29 204147.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/366359/4468d031-5a61-efc5-3a03-f1fe110d383c.png)

デフォルト状態でもリッチな見た目に進化しましたが，背景やロゴ，配色やフォームの配置など細かくカスタマイズが可能になりました．GUIでデザインを確認しながらカスタマイズ可能な「ブランディングデザイナー」が提供され，コードを書かなくても見た目を作り込むことが可能です．

▼ マネージドログインのカスタマイズ例（ブランディングデザイナー画面）
![スクリーンショット 2024-12-31 194218.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/366359/ee213a9a-c228-7061-5196-cf1d9dd48fef.png)

## マネージドログインをCDKで触ってみる

ブランディングデザイナーが提供されているされているものの，継続的な開発・運用を想定するとCDKをはじめとするIaCで管理したくなるので，実際に試してみながら動きを確認します．

本記事では，執筆次点で最新のCDKおよびaws-cliを利用します．
- CDK: 2.173.4
- aws-cli: 2.22.26

### デフォルト状態をCDKで構築する

下記の記事で紹介されているので，ここでは割愛します．

https://zenn.dev/shikira/articles/c4032b96231f7e

### ブランディングデザイナーでカスタマイズした画面のCDK適用を検討する

上記の記事でも紹介されているように，マネージドログインのカスタマイズ結果をCDK等のIaCで管理するには，一度ブランディングデザイナーでカスタマイズした結果をAPIで取得し，IaCに埋め込むのが現段階でベストプラクティスとされているようです．

https://docs.aws.amazon.com/cdk/api/v2/docs/aws-cdk-lib.aws_cognito.CfnManagedLoginBranding.html

> As a best practice, modify the output of DescribeManagedLoginBrandingByClient into the request parameters for this operation. To get all settings, set ReturnMergedResources to true . For more information, see API and SDK operations for managed login branding

CDKを使ってデフォルト状態から構築したマネージドログインを，ブランディングデザイナーでカスタマイズしてそのままにしておく場合，CFnスタックのドリフト状態が`MODIFIED`となってしまい，以後スタック更新が意図通りとならない可能性があります．そのため，ブランディングデザイナーのカスタマイズ結果をCDKに取り込むことが必要になります．

![スクリーンショット 2024-12-29 224041.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/366359/50f97a4d-5412-d4e3-59e8-3f59547dac62.png)


#### APIでカスタマイズ状態を取得する

``` bash: リクエスト
aws cognito-idp describe-managed-login-branding-by-client --user-pool-id <userPoolId> --client-id <clientId> > output.jso
```

``` json: レスポンス例（一部省略）
{
    "ManagedLoginBranding": {
        "ManagedLoginBrandingId": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
        "UserPoolId": "ap-northeast-1_XXXXXXXXX",
        "UseCognitoProvidedValues": false,
        "Settings": {
            "components": {
                "secondaryButton": {
                    "lightMode": {
                        "hover": {
                            "backgroundColor": "f2f8fdff",
                            "borderColor": "033160ff",
                            "textColor": "033160ff"
                            ︙
        "Assets": [
            {
                "Category": "FORM_LOGO",
                "ColorMode": "LIGHT",
                "Extension": "PNG",
                "Bytes": "・・・"
            },
            {
                "Category": "PAGE_BACKGROUND",
                "ColorMode": "LIGHT",
                "Extension": "JPEG",
                "Bytes": "・・・"
            }
        ]
    }
}

```

#### APIで取得したカスタマイズ状態をCDKに埋め込む

上記のAPIレスポンスの`Settings`および`Assets`をCDKの`CfnManagedLoginBranding`に埋め込みます．

```ts: 埋め込み例（コメントアウト部分の理由は後述）
import * as cdk from 'aws-cdk-lib';
import { Construct } from 'constructs';

interface CognitoManagedLoginCdkStackProps extends cdk.StackProps {
  url?: string
  redirectPath?: string
}

const defaultUrl = 'https://example.com';
const defaultRedirectPath = '/auth';

export class CognitoManagedLoginCdkStack extends cdk.Stack {
  constructor(scope: Construct, id: string, props: CognitoManagedLoginCdkStackProps) {
    super(scope, id, props);
    const url = props.url || defaultUrl;
    const redirectPath = props.redirectPath || defaultRedirectPath;

    const userPool = new cdk.aws_cognito.UserPool(this, 'UserPool', {
      signInAliases: {
        email: true,
      },
      removalPolicy: cdk.RemovalPolicy.DESTROY
    })

    const userPoolClient = new cdk.aws_cognito.UserPoolClient(this, 'UserPoolClient', {
      userPool: userPool,
      generateSecret: true,
      oAuth: {
        callbackUrls: [`${url}${redirectPath}`],
        logoutUrls: [url],
        flows: {
          authorizationCodeGrant: true,
          implicitCodeGrant: false
        }
      },
    })

    const cfnUserPoolDomain = new cdk.aws_cognito.CfnUserPoolDomain(this, 'UserPoolDomain', {
      domain: `managed-login-cdk-${process.env.CDK_DEFAULT_ACCOUNT}`,
      userPoolId: userPool.userPoolId,
      managedLoginVersion: 2,
    });

    new cdk.aws_cognito.CfnManagedLoginBranding(this, 'ManagedLoginBranding', {
      userPoolId: userPool.userPoolId,
      clientId: userPoolClient.userPoolClientId,
      returnMergedResources: true,
      // settingsの埋め込み部分
      settings:{
        components:{
          primaryButton: {
            lightMode: {
              defaults: {
                  "backgroundColor": "ff0000ff",
                  "textColor": "00ff00ff"
              },
            }
          },
          form: {
            lightMode: {
                backgroundColor: "ffffffff",
                borderColor: "c6c6cdff"
            },
            // borderRadius: 0,
            // logo: {
            //    location: "CENTER",
            //    position: "TOP",
            //    formInclusion: "IN",
            //    enabled: true,
            //}
          },
        }
      },
      // assetsの埋め込み部分
      // assets: [
      //  {
      //    category: "FORM_LOGO",
      //    colorMode: "LIGHT",
      //    extension: "PNG",
      //    bytes: "・・・"
      //  }
      ]
    })

    // output
    new cdk.CfnOutput(this, 'ManagedLoginUrl', {
      value: `https://${cfnUserPoolDomain.domain}.auth.${process.env.CDK_DEFAULT_REGION}.amazoncognito.com/login?lang=ja&response_type=code&client_id=${userPoolClient.userPoolClientId}&redirect_uri=${url}${redirectPath}`
    })
    new cdk.CfnOutput(this, 'UserPoolId', {
      value: userPool.userPoolId
    })
    new cdk.CfnOutput(this, 'UserPoolClientId', {
      value: userPoolClient.userPoolClientId
    })
  }
}
```

実際にスタックを更新してみると，以下のことが分かりました．
- `returnMergedResources`をtrueに設定しておけば，`settings`に必ずしも全てのプロパティを設定する必要はなく，マネージドログインの標準スタイルのままでよい設定は明記しなくても問題ない
- `assets`の指定を省略しても，カスタマイズした画像はデフォルト画像に戻らない

#### 課題

ベストプラクティスとされているフローに従い，カスタマイズ結果をCDKに埋め込んでみましたが，いくつか課題が見えてきました．

##### 1. 画像ファイルがCDKのテンプレートのサイズ制限に引っかかる

APIで取得したカスタマイズ結果には，背景やロゴに設定した画像のバイナリ文字列が含まれます．CDKで管理するためには，assetsにこれらのバイナリを埋め込む必要がありますが，CDKのテンプレートにはサイズ制限があり，これに引っかかる可能性が高いです．

```text
 failed: ValidationError: Template may not exceed 1000000 bytes in size.
```

こういったサイズ問題はAPIを叩くうえでも問題になり，AWS公式としても下記のようにアナウンスしています．

> This operation has a 2-megabyte request-size limit and include the CSS settings and image assets for your app client. Your branding settings might exceed 2MB in size. Amazon Cognito doesn't require that you pass all parameters in one request and preserves existing style settings that you don't specify. If your request is larger than 2MB, separate it into multiple requests, each with a size smaller than the limit.

　ただしAPIと違って，CDKをはじめとするIaCはリクエストを分割するというよりは，設定内容を宣言的に記述する使い方が通常です．そのため，現状でCDKに画像のバイナリを埋め込むのは難しいかと思いました．上記で述べたように，CDKでassetsの設定を省略したとしてもカスタマイズした画像は置換されないので，画像はCDKで管理せずにブランディングデザイナーを使って手動管理するのが現実的かもしれません．

　また上記のアナウンスでは，指定していない既存のスタイル設定は保持される旨の記載がありますが，これは恐らくassetsに対しての言及で，settings（フォームの色などのスタイル設定）に関しては指定していない既存のスタイル設定はデフォルトに戻るので，誤解しやすい部分でもあります．この動きはCDKだけでなく素のAPIでも同様でした．

##### 2. Valueが文字列でない設定値でエラーになり，CDKやCFnでその設定方法が不明

上記のCDK埋め込み例のコメントアウト部分にもあるように，`borderRadius`や`enabled`といった数値やboolの設定値をCDKで指定した場合，下記のようなエラーとなってしまいます．

```text: エラー例
 Validation errors: [{property: $.components.form.borderRadius, errorType: InvalidDataType}]
```

もちろんこれらの設定値は`describe-managed-login-branding-by-client`のAPIのレスポンスから転記した内容がベースで，パラメータとしては適切だと思われますが，2024年末の現段階ではエラーとなってしまいます．CFnにおいても同様です．

```yaml: CFnテンプレート例（NG）
AWSTemplateFormatVersion: '2010-09-09'
Resources:
  ManagedLoginBranding:
    Type: AWS::Cognito::ManagedLoginBranding
    Properties:
      ClientId: <clientId>
      ReturnMergedResources: true
      Settings: {"components": {
        "form": {
          "borderRadius": 10,
        }
      }}
      UserPoolId: "ap-northeast-1_XXXXXXX"
```

一方で，プレーンなAPIは数値の設定項目であってもエラーになりません．

```bash: APIのリクエスト例（OK）
aws cognito-idp update-managed-login-branding --managed-login-branding-id <マネージドログインのbrandingID> --settings '{"components": {"form": {"borderRadius": 0}}}' --user-pool-id ap-northeast-1_XXXXXXX
```

#### 課題を踏まえた現段階の落としどころ

1. CDKやCFnでIaC化をするのは見送る
    - ブランディングデザイナーがGUIベースで使いやすいので，手動運用でしばらく様子を見るのが無難かもしれません
1. CDKやCFnで設定してもエラーにならない項目だけ明記する
    - 画像はそもそもIaCで明示的に設定しなくてもブランディングデザイナーでカスタマイズした画像が差し変わらないので，画像はブランディングデザイナーで管理する
    - 大抵の設定項目（ボタンの色など）は，プロパティのValueが文字列なのでCDKやCFnに埋め込んでも問題は起きない
    - `borderRadius`は，見た目上の重要度はさほど高くなさそう
    - ロゴの有無のbool値はネックかもしれない
1. CustomResourceやCI/CDパイプラインの中でゴリゴリにAPIを叩く
    - APIは概ね動作しそうなので技術的には可能そう

## 結論

　ログイン画面はユーザ体験の最初である場合も多く，その見た目が良くなることは大切だと考えています．その点，今回のマネージドログインのリリースは大変嬉しいものです．
　そこでCognitoのマネージドログインをCDKやCFnで設定してみましたが，CDKの場合は現状でL1コンストラクトのみの提供であったり，設定値の管理運用が難しかったりと，まだまだ使いこなすためには工夫が必要そうな感触を得ました．まだ新しい機能なので，これからのアップデートに期待していきたいと思います．個人的には，画像はバイナリで扱うのではなく，S3にアップロードされている画像を指定できると便利になると思いました．

また，数値やboolの設定値のエラー回避方法が分かる方はコメントいただけますと幸いです．