🌟

【Flutter×Supabase】Apple Signin の実装

に公開

Apple Sign-inは現代のiOSアプリでは必須の機能となっています。この記事では、FlutterアプリでSupabaseを使用してApple Sign-inを実装する方法を初学者向けに詳しく解説します。認証の仕組みから実装までステップバイステップで説明していきます。

認証フローの理解

認証フローの各ステップ

  1. 初期認証リクエスト:

    • ユーザーが「Appleでサインイン」ボタンをタップ
    • FlutterアプリがApple Sign-inを初期化して認証画面を表示
  2. Apple認証プロセス:

    • ユーザーがAppleアカウントで承認
    • Apple認証サーバーからidTokenとauthorizationCodeが返却される
  3. Supabase認証処理:

    • 取得したトークンをSupabaseに送信
    • SupabaseがAppleと通信してトークンを検証
    • 検証成功後、ユーザー情報が登録またはログイン処理される

環境構築

1. 必要なパッケージの追加

pubspec.yamlに以下のパッケージを追加します:

dependencies:
  flutter:
    sdk: flutter
  supabase_flutter: ^1.10.25
  sign_in_with_apple: ^5.0.0
  crypto: ^3.0.3

2. Apple Developer設定

2.1 Apple Developer Programへの登録

Apple Sign-inを実装するには、Apple Developer Program(年会費119ドル)への登録が必要です。

2.2 App ID(Bundle Identifier)の設定

  1. Apple Developer Portalにアクセス

  2. 「Certificates, Identifiers & Profiles」を選択

  3. 「Identifiers」から新しいApp IDを登録またはすでにあるIDを選択

  4. 「Sign In with Apple」機能を有効化

2.3 Service IDの作成

  1. 「Identifiers」から「Services IDs」を選択し、新規作成

  2. 「Description」「Identifier」を設定

  3. 「Configure」をクリックし、再度作成されたものをクリック。signin with Appleにて「Configure」で以下を設定:

    • Webドメイン: your-project.supabase.co
    • リターンURL: https://your-project.supabase.co/auth/v1/callback


3. Supabase設定

3.1 認証プロバイダの設定

  1. Supabaseダッシュボードにログイン
  2. 「Authentication」→「Providers」へ移動
  3. 「Apple」を有効化
  4. 必要情報を入力:
    • Service ID: 先ほど作成したService ID
    • Team ID: Apple Developer AccountのTeam ID
    • Key ID: 後で作成する秘密鍵のID
    • Private Key: 後で作成する秘密鍵

3.2 秘密鍵の作成

  1. Apple Developer Portalの「Certificates, Identifiers & Profiles」へ移動

  2. 「Keys」を選択し、新しい鍵を作成

  3. 作成したApp IDの選択

  4. private keyのダウンロード

以下の手順でclient_secretを生成する。

  1. ruby-jwtをインストールする。
sudo gem install jwt
  1. 以下の内容でsecret_gen.rbを作成
require "jwt"

key_file = "Path to the private key"
team_id = "Your Team ID"
client_id = "The Service ID of the service you created"
key_id = "The Key ID of the private key"

validity_period = 180 # In days. Max 180 (6 months) according to Apple docs.

private_key = OpenSSL::PKey::EC.new IO.read key_file

token = JWT.encode(
	{
		iss: team_id,
		iat: Time.now.to_i,
		exp: Time.now.to_i + 86400 * validity_period,
		aud: "https://appleid.apple.com",
		sub: client_id
	},
	private_key,
	"ES256",
	header_fields=
	{
		kid: key_id
	}
)
puts token
  1. ruby secret_gen.rbを実行→出力されたものをコピー
  2. 内容をSupabase設定に貼り付け

3.3 データベース設定

ユーザーメタデータを保存するためのテーブルを作成します:

-- カスタムユーザーメタデータテーブル
create table public.user_metadata (
  id uuid references auth.users on delete cascade,
  apple_id text unique,
  email text,
  full_name text,
  avatar_url text,
  provider text,
  last_sign_in timestamp with time zone,
  primary key (id)
);

-- RLSポリシー設定
alter table public.user_metadata enable row level security;

create policy "Users can read own metadata"
  on public.user_metadata for select
  using ( auth.uid() = id );

実装手順

1. Supabaseの初期化

まず、main.dartでSupabaseを初期化します:

import 'package:flutter/material.dart';
import 'package:supabase_flutter/supabase_flutter.dart';

void main() async {
  WidgetsFlutterBinding.ensureInitialized();
  
  await Supabase.initialize(
    url: 'YOUR_SUPABASE_URL',
    anonKey: 'YOUR_SUPABASE_ANON_KEY',
  );
  
  runApp(MyApp());
}

class MyApp extends StatelessWidget {
  
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Apple Sign In Demo',
      theme: ThemeData(
        primarySwatch: Colors.blue,
      ),
      home: AuthPage(),
    );
  }
}

2. Appleサインイン機能の実装

認証ページとサインイン機能を実装します:

import 'package:flutter/material.dart';
import 'package:sign_in_with_apple/sign_in_with_apple.dart';
import 'package:crypto/crypto.dart';
import 'dart:convert';
import 'dart:math';
import 'package:supabase_flutter/supabase_flutter.dart';

class AuthPage extends StatefulWidget {
  
  _AuthPageState createState() => _AuthPageState();
}

class _AuthPageState extends State<AuthPage> {
  final _supabase = Supabase.instance.client;
  bool _isLoading = false;
  String? _errorMessage;

  
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text('Apple Sign In Demo'),
      ),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            if (_isLoading)
              CircularProgressIndicator()
            else
              SignInWithAppleButton(
                onPressed: _signInWithApple,
                style: SignInWithAppleButtonStyle.black,
              ),
            if (_errorMessage != null)
              Padding(
                padding: const EdgeInsets.all(16.0),
                child: Text(
                  _errorMessage!,
                  style: TextStyle(color: Colors.red),
                ),
              ),
          ],
        ),
      ),
    );
  }

  /// Apple Sign Inを実行する関数
  Future<void> _signInWithApple() async {
    try {
      setState(() {
        _isLoading = true;
        _errorMessage = null;
      });

      // ランダムな文字列を生成(nonceに使用)
      final rawNonce = _generateRandomString();
      final nonce = _sha256ofString(rawNonce);

      // Apple Sign Inを実行
      final credential = await SignInWithApple.getAppleIDCredential(
        scopes: [
          AppleIDAuthorizationScopes.email,
          AppleIDAuthorizationScopes.fullName,
        ],
        nonce: nonce,
      );

      // AppleからのレスポンスからidTokenとauthorizationCodeを取得
      final idToken = credential.identityToken;
      final authCode = credential.authorizationCode;
      
      if (idToken == null) {
        throw 'Apple Sign Inに失敗しました:IDトークンが取得できませんでした';
      }

      // Supabaseで認証
      final response = await _supabase.auth.signInWithIdToken(
        provider: OAuthProvider.apple,
        idToken: idToken,
        nonce: rawNonce,
      );

      // ユーザー情報を取得
      final user = response.user;
      if (user != null) {
        // 初回ログイン時は追加情報をメタデータテーブルに保存
        if (credential.givenName != null || credential.familyName != null) {
          final fullName = [
            credential.givenName,
            credential.familyName,
          ].where((name) => name != null).join(' ');
          
          await _supabase.from('user_metadata').upsert({
            'id': user.id,
            'apple_id': user.id,
            'email': credential.email,
            'full_name': fullName,
            'provider': 'apple',
            'last_sign_in': DateTime.now().toIso8601String(),
          });
        }
        
        // ログイン成功
        Navigator.pushReplacement(
          context,
          MaterialPageRoute(builder: (context) => HomePage(user: user)),
        );
      }
    } catch (e) {
      setState(() {
        _errorMessage = '認証エラー: ${e.toString()}';
      });
    } finally {
      setState(() {
        _isLoading = false;
      });
    }
  }

  /// ランダムな文字列を生成する関数
  String _generateRandomString() {
    final random = Random.secure();
    return base64Url.encode(List<int>.generate(32, (_) => random.nextInt(256)));
  }

  /// SHA256ハッシュを生成する関数(nonceに必要)
  String _sha256ofString(String input) {
    final bytes = utf8.encode(input);
    final digest = sha256.convert(bytes);
    return digest.toString();
  }
}

class HomePage extends StatelessWidget {
  final User user;
  
  HomePage({required this.user});
  
  
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text('ホーム'),
        actions: [
          IconButton(
            icon: Icon(Icons.logout),
            onPressed: () async {
              await Supabase.instance.client.auth.signOut();
              Navigator.pushReplacement(
                context,
                MaterialPageRoute(builder: (context) => AuthPage()),
              );
            },
          ),
        ],
      ),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            Text('ログイン成功!'),
            SizedBox(height: 20),
            Text('ユーザーID: ${user.id}'),
            SizedBox(height: 10),
            Text('メール: ${user.email ?? "不明"}'),
          ],
        ),
      ),
    );
  }
}

iOS設定

Flutter側の実装が終わったら、iOS固有の設定を行います。

1. Info.plistの設定

ios/Runner/Info.plistを開き、以下のエントリを追加します:

<key>CFBundleURLTypes</key>
<array>
  <dict>
    <key>CFBundleTypeRole</key>
    <string>Editor</string>
    <key>CFBundleURLSchemes</key>
    <array>
      <!-- Apple Sign Inの認証にServiceIDを使用 -->
      <string>your.service.id</string>
    </array>
  </dict>
</array>

2. エンタイトルメントの設定

ios/Runner.entitlementsファイルがなければ作成し、以下の内容を記述します:

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
  <key>com.apple.developer.applesignin</key>
  <array>
    <string>Default</string>
  </array>
</dict>
</plist>

次に、Xcodeでエンタイトルメントを有効にします:

  1. iOS Runnerプロジェクトを開く: open ios/Runner.xcworkspace
  2. Runnerプロジェクトを選択
  3. 「Signing & Capabilities」タブを選択
  4. 「+ Capability」をクリックし、「Sign In with Apple」を追加

トラブルシューティング

一般的な問題と解決方法

1. 認証エラー

  • 問題: Sign in failed: com.apple.authkit.authentication.authkitError -10000
  • 解決策:
    • Apple Developer Portalの設定を確認
    • Service IDのリターンURLが正しいか確認
    • Supabaseの設定が正しいか確認

2. identityTokenがnull

  • 問題: Apple認証後にidentityTokennullになる
  • 解決策:
    • nonceの生成が正しいか確認
    • デバイスのAppleアカウント設定を確認

3. クロスプラットフォーム対応

  • 問題: Android端末でApple Sign-inが動作しない
  • 解決策:
    • Webビューを使用してAndroidでも対応可能
    • 条件分岐を追加:
Widget buildSignInButton() {
  if (Platform.isIOS) {
    return SignInWithAppleButton(
      onPressed: _signInWithApple,
      style: SignInWithAppleButtonStyle.black,
    );
  } else {
    return ElevatedButton.icon(
      onPressed: _signInWithApple,
      icon: Icon(Icons.apple),
      label: Text('Appleでサインイン'),
    );
  }
}

まとめ

この記事では、FlutterとSupabaseを使用してApple Sign-inを実装する方法を解説しました。Apple Sign-inはiOSアプリでは必須の機能であり、適切に実装することでユーザーに簡単かつセキュアなログイン体験を提供できます。

実装のポイント:

  1. Apple Developer Portalでの正確な設定
  2. Supabaseでの認証プロバイダ設定
  3. nonceの適切な生成と検証
  4. ユーザーメタデータの保存と管理

これらのステップを丁寧に実装することで、安全で使いやすいApple Sign-in機能を備えたアプリを開発できます。

Discussion