Flutter web、amplify_flutter、Amazon Coginto、KeycloakでSAMLフェデレーション(SSO)

2023/05/07に公開

0. はじめに

今年のGWもまもなく終わります。
終わってしまうその前に、FlutterでSAMLフェデレーション(SSO)を試したいと思いました。

FlutterでSAMLってかなりニッチ・・・
iOSやAndroidでは試していませんのでご了承ください。Flutter webです。

1. ゴール

gifが荒いのですが、動画を添付します。

左側がFlutter web、右側がKeycloakのコンソールです。
「Sign in saml」を選択すると、Keycloakのサインイン画面へリダイレクトされます。(ここでSAML Requestが送信されます。)
サインインに成功するとアプリの画面へリダイレクトされ、画面上に氏名が表示されます。
このとき、右側のKeycloakのコンソールにて、Sessionが生成されていることが分かります。

サインインに成功したため、次に「Sign out」を選択します。
するとサインアウトのSAMLリクエストがKeycloakへ送信されて、サインアウト後にアプリのトップ画面へリダイレクトされることが分かります。
このとき、右側のKeycloakのコンソールにてSessionが削除されていることが分かります。

コードはGitHubに置きました。
https://github.com/motucraft/saml_sample

1.1 サンプルコード

  • pubspec.yaml
name: saml_sample
description: A new Flutter project.
publish_to: 'none'
version: 1.0.0+1
environment:
  sdk: '>=3.0.0 <4.0.0'

dependencies:
  flutter:
    sdk: flutter
  cupertino_icons: ^1.0.2
  amplify_flutter: ^1.1.0
  amplify_auth_cognito: ^1.1.0

dev_dependencies:
  flutter_test:
    sdk: flutter
  flutter_lints: ^2.0.0

flutter:
  uses-material-design: true
  • main.dart
import 'package:amplify_auth_cognito/amplify_auth_cognito.dart';
import 'package:amplify_flutter/amplify_flutter.dart';
import 'package:flutter/material.dart';

import 'amplifyconfiguration.dart';

void main() {
  runApp(const MyApp());
}

class MyApp extends StatefulWidget {
  const MyApp({super.key});

  
  State<MyApp> createState() => _MyAppState();
}

class _MyAppState extends State<MyApp> {
  bool isSignedIn = false;
  late String? firstName;
  late String? lastName;

  
  void initState() {
    super.initState();
    _configureAmplify();

    WidgetsBinding.instance.addPostFrameCallback((_) async {
      isSignedIn = await _isUserSignedIn();
      if (isSignedIn) {
        final result = await Amplify.Auth.fetchAuthSession();
        final cognitoUserPoolTokens = result.toJson()['userPoolTokens'] as CognitoUserPoolTokens;
        final idToken = cognitoUserPoolTokens.idToken;

        firstName = idToken.givenName;
        lastName = idToken.familyName;
      }
      setState(() {});
    });
  }

  Future<void> _configureAmplify() async {
    try {
      final auth = AmplifyAuthCognito();
      await Amplify.addPlugin(auth);

      await Amplify.configure(amplifyconfig);
    } on Exception catch (e) {
      safePrint('An error occurred configuring Amplify: $e');
    }
  }

  
  Widget build(BuildContext context) {
    return MaterialApp(
      home: SafeArea(
        child: Scaffold(
          body: Center(
            child: Column(
              mainAxisAlignment: MainAxisAlignment.center,
              children: [
                ElevatedButton(
                  onPressed: () async {
                    await _signInSaml();
                    final isUserSignedIn = await _isUserSignedIn();
                    setState(() {
                      isSignedIn = isUserSignedIn;
                    });
                  },
                  child: const Text('Sign in saml'),
                ),
                const SizedBox(height: 24),
                ElevatedButton(
                  onPressed: () async {
                    final isUserSignedIn = await _isUserSignedIn();
                    setState(() {
                      isSignedIn = isUserSignedIn;
                    });
                  },
                  child: const Text('Current user'),
                ),
                const SizedBox(height: 24),
                ElevatedButton(
                  onPressed: () async {
                    Amplify.Auth.signOut();
                    final isUserSignedIn = await _isUserSignedIn();
                    setState(() {
                      isSignedIn = isUserSignedIn;
                    });
                  },
                  child: const Text('Sign out'),
                ),
                const SizedBox(height: 24),
                if (isSignedIn)
                  Row(
                    mainAxisAlignment: MainAxisAlignment.center,
                    children: [
                      Text(
                        '$lastName $firstName',
                        style: const TextStyle(fontSize: 36),
                      ),
                    ],
                  ),
              ],
            ),
          ),
        ),
      ),
    );
  }

  Future<void> _signInSaml() async {
    final result = await Amplify.Auth.signInWithWebUI(
      provider: const AuthProvider.saml('SampleProvider'),
    );
    debugPrint('result=$result');
  }

  Future<bool> _isUserSignedIn() async {
    final result = await Amplify.Auth.fetchAuthSession();
    if (result.isSignedIn) {
      final cognitoUserPoolTokens = result.toJson()['userPoolTokens'] as CognitoUserPoolTokens;
      final idToken = cognitoUserPoolTokens.idToken;
      debugPrint('userId=${idToken.userId}');
      debugPrint('name=${idToken.name}');
      debugPrint('familyName=${idToken.familyName}');
      debugPrint('givenName=${idToken.givenName}');
    }

    return result.isSignedIn;
  }
}

AuthProvider.saml('SampleProvider')の箇所で、Cognitoのフェデレーテッドアイデンティティプロバイダーを指定しています。(作成方法は、下の方で説明しています。)

const amplifyconfig = ''' {
    "UserAgent": "aws-amplify-cli/2.0",
    "Version": "1.0",
    "auth": {
        "plugins": {
            "awsCognitoAuthPlugin": {
                "UserAgent": "aws-amplify-cli/0.1.0",
                "Version": "0.1.0",
                "IdentityManager": {
                    "Default": {}
                },
                "CognitoUserPool": {
                    "Default": {
                        "PoolId": "<ユーザープールID>",
                        "AppClientId": "<クライアントID>",
                        "Region": "ap-northeast-1"
                    }
                },
                "Auth": {
                    "Default": {
                        "OAuth": {
                            "WebDomain": "sample-saml.auth.ap-northeast-1.amazoncognito.com",
                            "AppClientId": "<クライアントID>",
                            "SignInRedirectURI": "http://localhost:8000/",
                            "SignOutRedirectURI": "http://localhost:8000/",
                            "Scopes": [ "email", "openid", "profile" ]
                        },
                        "authenticationFlowType": "USER_SRP_AUTH"
                    }
                }
            }
        }
    }
}''';

<ユーザープールID>、<クライアントID>はAWSコンソールのCognitoユーザープールから確認できます。"WebDomain"は、Cognitoドメインです(URIスキームは付けない)。これもAWSコンソールから確認できます。
環境に応じて置き換えてください。

ユーザープールの作成は、下の方で説明しています。

1.2 Flutter webの起動

Flutter webを8000番ポートで起動します。
リダイレクトのURIなどに影響するため、ここではポート番号を8000番に固定しています。

$ flutter run -d chrome --web-port 8000

2. 環境

  • Flutter
    version:3.10.0 (Dart 3 が正式リリースされましたね)
  • amplify_flutter
    https://pub.dev/packages/amplify_flutter
    amplify_flutterは、2023年4月にv1がstableとなりました。
    今回は、2023年5月現在の最新バージョンv1.1.0を利用します。
  • Keycloak
    SAMLのIdpとして、Keycloakを利用します。Keycloakはlocalhostに構築します。
    verion:21.1.1
  • AWS Cognito
    サードパーティーのIdpを利用する場合(私の場合はkeycloak)、CognitoはSAMLフローのSP(Service Provider)として振る舞います。それに加えて、OIDC(OpenID Connect)のOP(OpenID Provider)としても振る舞うことになります。
    そして、amplify_flutterは、Cognitoに対してOIDCのアクセスを行います。
    (SAMLをやりたいのに、OIDCの話が出てきて私は混乱してしまいました...)

AWSは個人アカウントの無料枠を使用しました。
ただし、CognitoでSAMLフェデレーションを利用する場合の無料枠は、50MAUのようですので注意。

3. Keycloak

SAMLのIdpとして、Keycloakを利用します。Keycloakはlocalhostに構築します。
Rancher Desktopを用いて、コンテナ上に構築することにしました。

  • docker-compose.yml
version: '3'
services:
  keycloak:
    image: quay.io/keycloak/keycloak:21.1.1
    container_name: keycloak
    tty: true
    stdin_open: true
    ports:
      - "8080:8080"
    volumes:
      - ./data/keycloak:/opt/keycloak/data
    environment:
      KEYCLOAK_ADMIN: admin
      KEYCLOAK_ADMIN_PASSWORD: admin
    command:
      - start-dev --http-relative-path /auth

docker-compose.ymlが存在するディレクトリで、以下のようにコンテナを起動します。

$ docker-compose up -d

Keycloakが起動すると、以下のURLでアクセスできます。
http://localhost:8080/auth/

コンソールには、docker-compose.ymlで環境変数へ設定したadmin(パスワードも同じ)でログインできます。

3.1 レルム作成

adminでログインできたら、まずはレルムを作成します。
左上のmasterと表示されているドロップダウンを選択すると表示される、「Create Realm」を選択します。

  • Realm name:samplerealm

samplerealmというレルムを作成しました。

3.2 ユーザ作成

samplerealm内に新規ユーザを作成します。
メニューの「Users」を選択します。

「Create new user」を選択します。
以下のようなユーザーを作成しました。
(サンプルなので、メールアドレスは存在しないダミーのものを指定し、Email verifiedをYesとしました。

  • Username:testuser
  • Email:testuser@foo.bar.jp
  • Email verified:Yes
  • First name:太郎
  • Last name:山田

ユーザーが作成されたら、「Credentials」タブを選択してパスワードを設定します。

「Set password」を選択し、パスワードを設定します。
初回ログイン時に変更を求められるのは面倒なので、TemporaryはOffで設定しました。

これでユーザの作成が終わりました。
確認のために、作成したユーザでKeycloakへサインインしてみましょう。

以下のURLへアクセスします。
http://localhost:8080/auth/realms/samplerealm/account

右上の「Sign in」を選択し、先程作成したユーザでサインインします。

問題なくサインインできればOKです。

3.3 レルムロール作成

メニューの「Realm roles」から、「Create role」を選択します。

  • Role name:myrole

ロール名を入力し、「Save」を選択します。ここではロール名を「myrole」としました。
ロールを作成したら、ユーザ「testuser」をロールに所属させます。


あとは、Keycloakの「Clients」を作成したいのですが、Cognitoの情報が必要になるため、一旦Cognitoの構築へ移ります。

4. AWS Cognito ユーザープール作成

Cognitoのユーザープールを作成します。(IDプールは不要です。)
「ユーザープールを作成」を選択します。

「フェデレーテッドアイデンティティプロバイダー」を選択し、フェデレーテッドサインインのオプションは「SAML」を選択します。

セキュリティ要件は、以下のように設定しました。

サインアップエクスペリエンスの設定は、デフォルトのまま「次へ」を選択しました。

メッセージ配信を設定については、「CognitoでEメールを送信」を選択しました。

フェデレーテッドアイデンティティプロバイダーを接続については、「後で」を選択しました。


アプリケーションを統合は、以下のように設定しました。

Flutter webは、localhostで8000番ポートで起動することにします。
そのため、「http://localhost:8000/ 」のコールバックURLを設定しました。

https://sample-saml.auth.ap-northeast-1.amazoncognito.com/saml2/idpresponse 」に関しては、この辺り(AWS公式ドキュメント)に説明があります。
https://docs.aws.amazon.com/ja_jp/cognito/latest/developerguide/cognito-user-pools-saml-idp.html

(キャプチャは、すご〜く縦長になってしまったので貼り付けるのを諦めました...)

最後に確認ページが表示されるため、「ユーザープールを作成」を選択します。

これでユーザープールが作成できました。
Keycloakの設定へ戻ります。

5. Keyclaok SAMLクライアント作成

先程作成したレルム「samplerealm」であることを確認し、メニューの「Clients」を選択します。

「Create client」を選択します。

クライアントには以下を設定しました。

「Save」を選択してクライアントを作成します。
クライアントが作成されたら、KeysタブのClient signature requiredをOFFで設定しました。

続いて、Client scopesタブを選択します。
Assigned client scopeのリンクを選択します。

「Configure a new mapper」を選択します。

Configure a new mapperのダイアログが表示されため、「User Property」を選択します。

以下のように、username、email、firstName、lastNameのマッパーを作成しました。




マッパー作成後には、以下のようにマッパーの一覧が確認できます。

最後に、クライアントのAdvancedタブを選択します。

サインアウト後にアプリケーショントップへリダイレクトさせるために、以下の設定を行いました。

5.1 Client Settings

  • Name ID format:persistent
  • Force name ID format:On

6. Keyclaok Metadataのエクスポート

メニューのRealm settingsから、Generalタブを選択すると、「SAML 2.0 Identity Provider Metadata」というリンクがあります。このリンクを選択して、Metadataを保存します。
XMLのファイルです。このMetadataにはX.509の証明書やIdpを一意に識別するためのentityIDなど、SPとIdpが信頼関係を構築する上で必要な情報が格納されています。

このMetadataを、Cognitoへインポートします。
今回はTLS(SSL)を利用しないため(Flutter webはlocalhostで実行しますし)、Require SSLは「None」で設定しておきます。

  • Require SSL:None

7. Cognito アイデンティティプロバイダー作成

AWSコンソールからCognitoのユーザープールへ進み、今回作成したユーザープール「test_user_pool」を選択し、「サインインエクスペリエンス」タブを選択します。

「アイデンティティプロバイダーを追加」を選択します。

  • フェデレーティッドサインインのオプション:SAML
  • プロバイダー名:SampleProvider
  • サインアウトフローを追加:ON
  • メタデータドキュメントをアップロード
    「ファイルを選択」があるため、ここでKeycloakからエクスポートしたMetadataのXMLファイルを指定します。
  • SAML プロバイダーとユーザープールの間で属性をマッピング
    Keycloakで作成したマッパーに対応した属性をマッピングしました。

「アイデンティティプロバイダーを追加」を選択して作成を完了します。

7.1 アプリケーションクライアント設定の更新

アイデンティティプロバイダーを作成したので、クライアント「sampleclient」が「SampleProvider」を使用するように設定します。「sampleclient」を選択して「ホストされた UI」の「編集」を選択します。
以下のように、アイデンティティプロバイダーとして「SampleProvider」を設定します。

これで、冒頭で記載したサンプルコードを用いて挙動を確認できると思います。

8. おわりに

OIDCに比べてSAMLって情報少なくないですか?歴史もありますし、エンタープライズで現役なのに。
Flutterを採用している環境下でも、既存の認証基盤とSAML連携させたいという要求やユースケースもあるのではないでしょうか。(私は気が進まないのですけど...)

単純なサンプルアプリでSAMLを試すだけでも、各種設定が必要だったり、それなりにSAMLの知識を求められたりもします。
情報も少なくて、設定の流れもつかめない状況があるのかなと思いまして、今回キャプチャ多めに記事を書いてみました。
(そのうち、構成がひと目で分かるような図を貼りたい。)

あぁ...そして魅惑のGWが終わる。
ウキウキだった1週間前に戻りたい...

参考

Discussion