Flutter Web、firebase_auth、KeycloakでSAMLフェデレーションを試す(SSO)

2023/07/17に公開

1.はじめに

https://zenn.dev/motu2119/articles/a70b611329d133

以前、上記の記事でAmazon Cogintoを利用したSAMLフェデレーションを試しました。
今回は、Firebase Authenticationを利用したSAML認証を試したいと思います。

IdPはlocalhostのKeycloakを利用します。
こちらで用意したものです。

https://zenn.dev/motu2119/articles/38f89f4d4c29b9

ソースはGitHubにあります。アプリを動作させた際の動画も貼り付けてあります。

https://github.com/motucraft/firebase_saml

2.Firebase

Firebase AuthenticationでSAMLを有効化します。

最初は上記画像の状態のはずです。
SAMLを利用するには、Identity Platformと連携する必要があるため、「アップグレード」を選択します。

「次へ」を選択します。

プランの変更点を確認し、「次へ」を選択します。

使用量の確認も「次へ」を選択します。

「新しいAuthenticationにアップグレードする」を選択します。

すると、OpenID ConnectとSAMLが有効化されます。
今回はSAMLを選択します。

Sign-in methodにてログインプロバイダを作成します。
SAMLを有効化して、以下の情報を設定しました。

SSOのURLはhttpsでないと設定できません。
これが理由で、前回の記事にてKeycloakをTLS/HTTPS化したのですね。

エンティティIDとSSOのURLは、IdP(私の場合はKeycloak)のメタデータから確認することができます。

Keycloakでは、上記の箇所からメタデータを入手することができます。

<md:EntityDescriptor xmlns="urn:oasis:names:tc:SAML:2.0:metadata" xmlns:md="urn:oasis:names:tc:SAML:2.0:metadata" xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion" xmlns:ds="http://www.w3.org/2000/09/xmldsig#" entityID="https://localhost:8443/auth/realms/samplerealm">
  <md:IDPSSODescriptor WantAuthnRequestsSigned="true" protocolSupportEnumeration="urn:oasis:names:tc:SAML:2.0:protocol">
    <md:KeyDescriptor use="signing">
      <ds:KeyInfo>
        <ds:KeyName>TMKpQwdRVlOWoBnc3Q31OS7P4ftShmSfNo4_LtfRio8</ds:KeyName>
        <ds:X509Data>
          <ds:X509Certificate>MIICpTCCAY0CBgGH9NYPmzANBgkqhkiG9w0BAQsFADAWMRQwEgYDVQQDDAtzYW1wbGVyZWFsbTAeFw0yMzA1MDcwNjA5NDJaFw0zMzA1MDcwNjExMjJaMBYxFDASBgNVBAMMC3NhbXBsZXJlYWxtMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAyfrUnV9xc51hjrvBcHb+4jmRCNtAu1PvVb1j0PUsA9Ic0C+S7zzXsSbyOX6MRLTZz8Al3fcUdbJGBOGu/F0zcI6FHyS2ZXg9+GQOJ/llHuTljQjx0ObSFL0D29te9NLqbXyuNUo8v78tCWAUTCOhbZYv42RDHJ/Up0GWKR6mG7adHJGS+N28w3k0WvvcBm82Ybhcu4OmGfSXfhLv76sUB2EbTyKf5Sb83sqvHrHoSWbbMgl1PvIqz6w9KMGgerPdE+d28qNRYvjYgH/Wbw3jhqhlGhP8jCdiso/RkR+so7DjQrcCO5nTMcac8UXl0zeHTolu4LheD7ViaO6N5Xe8cwIDAQABMA0GCSqGSIb3DQEBCwUAA4IBAQAvj/HIoVFAKPf6SfbwWWj5zy3brUKRKUx/MvN2t1FOgXcF5QV3tB3+g0VbnbI4u/bCWdGsZnOE5rxV/0qQoJhOVFVktPzvZxx8NjwGL7rE4FA4GnydWq1zvobzFL5fKKHZyWXNzYjFFabmj+VrNYGqVTD0vr/c7+55mUThTlY1XqC9tqNj4iMZWqN+scB3JneZz0rMaQbcyAr+jXCzW2ier1sINansknVlmJxrlm82LCS6hex9K0D6+Gb+0oSuWcyqq4F9Q8xBlONOwZ+PJpIPj7dJKbaT59SITWqNKmyzvaS0NAe0RNNU8TKBUaX256nJM3quAdNSe/3J8Ll0fAsU</ds:X509Certificate>
        </ds:X509Data>
      </ds:KeyInfo>
    </md:KeyDescriptor>
    <md:ArtifactResolutionService Binding="urn:oasis:names:tc:SAML:2.0:bindings:SOAP" Location="https://localhost:8443/auth/realms/samplerealm/protocol/saml/resolve" index="0">
    </md:ArtifactResolutionService>
    <md:SingleLogoutService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST" Location="https://localhost:8443/auth/realms/samplerealm/protocol/saml">
    </md:SingleLogoutService>
    <md:SingleLogoutService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect" Location="https://localhost:8443/auth/realms/samplerealm/protocol/saml">
    </md:SingleLogoutService>
    <md:SingleLogoutService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Artifact" Location="https://localhost:8443/auth/realms/samplerealm/protocol/saml">
    </md:SingleLogoutService>
    <md:SingleLogoutService Binding="urn:oasis:names:tc:SAML:2.0:bindings:SOAP" Location="https://localhost:8443/auth/realms/samplerealm/protocol/saml">
    </md:SingleLogoutService>
    <md:NameIDFormat>urn:oasis:names:tc:SAML:2.0:nameid-format:persistent</md:NameIDFormat>
    <md:NameIDFormat>urn:oasis:names:tc:SAML:2.0:nameid-format:transient</md:NameIDFormat>
    <md:NameIDFormat>urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified</md:NameIDFormat>
    <md:NameIDFormat>urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress</md:NameIDFormat>
    <md:SingleSignOnService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST" Location="https://localhost:8443/auth/realms/samplerealm/protocol/saml">
    </md:SingleSignOnService>
    <md:SingleSignOnService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect" Location="https://localhost:8443/auth/realms/samplerealm/protocol/saml">
    </md:SingleSignOnService>
    <md:SingleSignOnService Binding="urn:oasis:names:tc:SAML:2.0:bindings:SOAP" Location="https://localhost:8443/auth/realms/samplerealm/protocol/saml">
    </md:SingleSignOnService>
    <md:SingleSignOnService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Artifact" Location="https://localhost:8443/auth/realms/samplerealm/protocol/saml">
    </md:SingleSignOnService>
  </md:IDPSSODescriptor>
</md:EntityDescriptor>

エンティティIDは、EntityDescriptorエレメントのentityIDの値、SSOのURLはSingleSignOnServiceエレメントのurn:oasis:names:tc:SAML:2.0:bindings:HTTP-POSTの値を設定します。

入力したら「次へ」を選択します。

続いて、証明書の設定を求められます。
Keycloakの証明書は、以下のように入手できます。

Firebaseのコンソール上に、

先頭は「-----BEGIN CERTIFICATE-----」、末尾は「-----END CERTIFICATE-----」としてください。

という説明があります。署名証明書の変換にも同様の説明があります。
先頭、末尾を追加したら証明書の欄に貼り付けます。

続いて、「サービス プロバイダ(エンティティ ID)」は、「firebase_saml」で設定しました。
この名称は、Keyclaokにて作成するClinetの名称になります。アプリケーションを識別するための名称を設定すれば良いと思います。

「次へ」を選択すると、SAML統合の構成が表示されます。

ここで示されるコールバックURLは、Keycloak側に設定することになります。
「保存」を選択してログインプロバイダの設定を保存します。

これでFirebase側の設定は完了です。

Identity Platformと連携されていますので、念のためIdentity Platformも見ておきます。
GCPのコンソールから確認できます。

鉛筆アイコンを選択すると設定の中身を確認できます。

できてますね。

3.Keycloak

https://zenn.dev/motu2119/articles/a70b611329d133

Keycloakは、上記で作成したrealm(samplerealm)を利用していきます。
ユーザ(testuser)も上記で作成済みです。

Amazon Cognito用のClientは上記で作成しましたが、別途Firebase用のClientを作成します。

「Create client」を選択します。

  • Client type:SAML
  • Client ID:firebase_saml
  • Name:任意

上記のように設定し「Next」を選択します。
Client IDには、Firebaseの証明書を設定する箇所で設定したエンティティIDと同じ値を設定します。

Login settingsのValid redirect URIsには以下を設定します。

設定したら「Save」を選択します。
すると、作成したClientのSettingsタブが表示されると思います。

SAML capabilitiesを以下のように設定します。

  • Name ID format:email
  • Force name ID format:On
  • Force POST binding:On
  • Force artifact binding:Off
  • Include AuthnStatement:On
  • Incluce OneTimeUse Condition:Off
  • Optimize REDIRECT signing lookup:Off

Signature and Encryptionを以下のように設定します。

  • Sign documents:Off
  • Sign assertions:On
  • Signature algorithm:RSA_SHA256
  • SAML signature key name:NONE
  • Canonicalization mehtod:EXCLUSIVE

設定したら「Save」を選択します。

続いて、「Keys」タブを選択し、Client signature requiredをOffで設定します。
以下のとおりです。

これで、Keycloakの設定が終わりました。

4.Flutter WebでSAML認証してみる

SAMLの認証を試したいだけですので、Flutterのコードは以下のとおりでシンプルなものです。
signInWithPopup()メソッドを利用しています。
firebase_auth_platform_interface SAMLAuthProvider throws FallThroughErrorというissueがあったようなのですが、こちら(feat(auth, web): add SAMLProvider support to Web)で対応されていて、現在は利用できると思います。

import 'package:firebase_auth/firebase_auth.dart';
import 'package:firebase_core/firebase_core.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:simple_logger/simple_logger.dart';

import 'firebase_options.dart';

part 'main.g.dart';

late final FirebaseApp firebaseApp;

void main() async {
  WidgetsFlutterBinding.ensureInitialized();
  firebaseApp = await Firebase.initializeApp(
    options: DefaultFirebaseOptions.currentPlatform,
  );
  await FirebaseAuth.instance.userChanges().first;

  runApp(const ProviderScope(child: MyApp()));
}

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

  
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Firebase SAML Demo',
      theme: ThemeData(
        colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple),
        useMaterial3: true,
      ),
      home: const SAMLSample(),
    );
  }
}

class SAMLSample extends ConsumerWidget {
  const SAMLSample({super.key});

  
  Widget build(BuildContext context, WidgetRef ref) {
    final authController = ref.watch(authControllerProvider);
    return authController.when(
      data: (data) {
        return Scaffold(
          body: SafeArea(
            child: Center(
              child: Column(
                mainAxisAlignment: MainAxisAlignment.center,
                children: [
                  if (data == null)
                    ElevatedButton(
                      onPressed: () async => await ref
                          .read(authControllerProvider.notifier)
                          .samlSignIn(),
                      child: const Text('SAML Sign In'),
                    ),
                  if (data != null) ...[
                    const Text('Firebase SAML Sign-in SUCCESS!!!',
                        style: TextStyle(fontSize: 18)),
                    const SizedBox(height: 8),
                    SelectableText('Email: ${data.email!}',
                        style: const TextStyle(fontSize: 18)),
                    const SizedBox(height: 8),
                    SelectableText('Uid: ${data.uid}',
                        style: const TextStyle(fontSize: 18)),
                    const SizedBox(height: 24),
                    ElevatedButton(
                      onPressed: () async => await ref
                          .read(authControllerProvider.notifier)
                          .samlSignOut(),
                      child: const Text('Sign Out'),
                    ),
                  ]
                ],
              ),
            ),
          ),
        );
      },
      error: (error, stack) {
        logger.severe(error);
        logger.severe(stack);
        return Scaffold(
          body: Text(
            error.toString(),
            style: const TextStyle(color: Colors.red),
          ),
        );
      },
      loading: () => const Scaffold(
        body: Center(child: CircularProgressIndicator()),
      ),
    );
  }
}


SAMLAuthProvider samlAuth(SamlAuthRef ref) {
  return SAMLAuthProvider('saml.saml-provider');
}


class AuthController extends _$AuthController {
  final _auth = FirebaseAuth.instance;

  
  FutureOr<User?> build() async {
    _auth.userChanges().listen((user) {
      state = AsyncValue.data(user);
    });

    return state.value;
  }

  Future<void> samlSignIn() async {
    state = const AsyncLoading<User?>().copyWithPrevious(state);
    state = await AsyncValue.guard(() async {
      final userCredential = await FirebaseAuth.instanceFor(app: firebaseApp)
          .signInWithPopup(ref.read(samlAuthProvider));
      return userCredential.user;
    });
  }

  Future<void> samlSignOut() async {
    state = const AsyncLoading<User?>().copyWithPrevious(state);
    state = await AsyncValue.guard(() async {
      await FirebaseAuth.instance.signOut();
      return null;
    });
  }
}

final logger = SimpleLogger()
  ..setLevel(
    Level.ALL,
    includeCallerInfo: true,
  );

pubspec.yamlはこちらです。

name: firebase_saml
description: A new Flutter project.
publish_to: 'none'
version: 1.0.0+1

environment:
  sdk: '>=3.0.6 <4.0.0'

dependencies:
  flutter:
    sdk: flutter

  cupertino_icons: ^1.0.5
  firebase_core: ^2.15.0
  firebase_auth: ^4.7.0
  hooks_riverpod: ^2.3.6
  flutter_hooks: ^0.18.6
  simple_logger: ^1.9.0+2
  riverpod_annotation: ^2.1.1

dev_dependencies:
  flutter_test:
    sdk: flutter

  flutter_lints: ^2.0.2
  riverpod_generator: ^2.2.3
  build_runner: ^2.4.6
  custom_lint: ^0.4.0
  riverpod_lint: ^1.3.2

flutter:
  uses-material-design: true

Flutter Webを9000番ポートで起動します。
Valid redirect URIsに「 http://localhost:9000/ 」を設定しましたから、別のポート番号で起動してしまうと、SAMLのリダイレクトができません。

flutter run -d chrome --web-port 9000

起動しました。「SAML Sign In」というボタンがあるだけです。
9000番ポートで起動していますね。

「SAML Sign In」を選択してみます。

別のウィンドウが表示されてきました。
IdP(Keycalok)へアクセスしようとしていますね。

https://zenn.dev/motu2119/articles/38f89f4d4c29b9

上記で設定したとおり、自己署名証明書を利用していますのでブラウザはこのような表示になります。

「詳細設定」から「localhostにアクセスする(安全ではありません)」を選択します。

無事、Keycloakのサインイン画面が表示されました。
事前に作成済みのユーザ(ここではtestuser)でサインインしてみます。

サインインに成功すると、Keycloakのウィンドウが閉じ、アプリの画面へ戻ります。
SAMLでのサインインに成功し、ユーザのメールアドレスおよび、Firebase Authenticationが採番するUIDが表示できました。

SAML認証成功ですね。
Firebaseのコンソールも確認しておきましょう。

ユーザが作成できていますね。UIDもアプリに表示されたものと一致しています。

5.おわりに

ここでまでで、タイトルのとおり「Flutter Web、firebase_auth、KeycloakでSAMLフェデレーションを試す」ことができました。

ここまで来ると、SSO試したいですよね???

同じIdP(Keycloak)を利用しているのですから、Amazon Cognitoを利用するシステムとFirebaseを利用するシステムとでSSOできそうですよね。ただ、それぞれKeycloakのClientは別で設定しているので、これをどうやって連携させれば良いのでしょう...今度やってみます。

それから、FirebaseでSAML認証したとき、シングルサインアウトってどうやるのでしょうか。
FirebaseAuth.instance.signOut()だけでは、IdPのセッションが残ってしまうので、再認証をかけられないのです。

シングルサインアウトの方法が分からず、こちらのIssue(Feature Request?)を起票してみました。Cognitoだとできるのですけどね。

Firebaseというより、もしかしてIdentity Platformが対応していないのでは?と思っています。

6.参考文献

Discussion