Supabase Auth Dart

認証ライブラリSupabaseFlutterDartBaaSOAuthJWT多要素認証リアルタイム認証

認証ライブラリ

Supabase Auth Dart

概要

Supabase Auth DartはFlutterアプリケーション向けのオープンソース認証ライブラリです。Supabase BaaS(Backend as a Service)プラットフォームの一部として、メール認証、ソーシャルログイン、多要素認証、リアルタイム認証状態管理など包括的な認証機能を提供します。PostgreSQLデータベースとの完全統合により、スケーラブルで安全な認証システムを構築できます。

詳細

Supabase Auth Dartは2024年現在、Flutter開発における最も包括的な認証ソリューションの一つです。Googleアカウント、Apple ID、GitHub、Discord、Facebookなど20以上のOAuthプロバイダーをサポートし、メール認証、電話番号認証、マジックリンク認証に対応しています。JWT(JSON Web Token)ベースの認証を採用し、リアルタイム認証状態変更の監視、セッション管理、多要素認証(MFA)、行レベルセキュリティ(RLS)によるデータアクセス制御が可能です。Flutter Web、iOS、Android、macOS、Windows、Linuxなど全プラットフォームをサポートしており、特にFlutter 3.0以降で最適化されています。

主要機能

  • Flutter完全統合: Flutter 3.0+対応の公式パッケージ
  • 多様な認証方式: OAuth、メール、電話、マジックリンク対応
  • リアルタイム状態管理: 認証状態変更の即座な検知
  • 多要素認証: TOTP、SMS、メール2段階認証対応
  • セッション管理: 自動セッション更新とストレージ
  • 行レベルセキュリティ: PostgreSQLベースの細かいアクセス制御
  • ディープリンク対応: モバイルアプリの認証フロー最適化
  • カスタマイズ可能UI: 事前構築済みウィジェットと完全カスタマイズ

サポートプラットフォーム

  • モバイル: iOS、Android
  • デスクトップ: macOS、Windows、Linux
  • Web: Progressive Web App(PWA)対応
  • マルチプラットフォーム: 単一コードベースで全プラットフォーム対応

サポート認証プロバイダー

  • ソーシャル: Google、Apple、Facebook、GitHub、Discord、Twitter等20+プロバイダー
  • メール/パスワード: 従来型の認証
  • マジックリンク: パスワードレス認証
  • 電話番号: SMS認証
  • 匿名認証: ゲスト機能実装

メリット・デメリット

メリット

  • 完全なFlutter統合: Flutter専用設計で開発効率最大化
  • オープンソース: MIT ライセンスで商用利用可能
  • スケーラビリティ: Supabaseインフラによる自動スケーリング
  • リアルタイム機能: WebSocket基盤のリアルタイム認証状態
  • 包括的セキュリティ: JWT + RLS による強固なセキュリティ
  • ゼロ設定: 最小限の設定で本格認証システム構築
  • 豊富なドキュメント: 日本語対応含む充実したドキュメント
  • アクティブ開発: Supabaseチームによる継続的な開発とサポート

デメリット

  • ベンダーロックイン: Supabaseエコシステム依存
  • ネットワーク依存: オフライン時の機能制限
  • 料金体系: 大規模利用時のコスト(無料枠は充実)
  • 学習コスト: Supabase全体のエコシステム理解が必要
  • カスタマイズ制限: Supabaseサーバー側の制約
  • データ所在地: サーバー地域選択の制約

参考ページ

書き方の例

基本セットアップ

# pubspec.yaml
dependencies:
  flutter:
    sdk: flutter
  supabase_flutter: ^2.0.0
  supabase_auth_ui: ^0.1.0

dev_dependencies:
  flutter_test:
    sdk: flutter

初期化とプロジェクト設定

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

Future<void> main() async {
  WidgetsFlutterBinding.ensureInitialized();
  
  await Supabase.initialize(
    url: 'YOUR_SUPABASE_URL',
    anonKey: 'YOUR_SUPABASE_ANON_KEY',
    authOptions: const FlutterAuthClientOptions(
      authFlowType: AuthFlowType.pkce,
    ),
  );
  
  runApp(MyApp());
}

final supabase = Supabase.instance.client;

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Supabase Auth Demo',
      home: AuthGate(),
    );
  }
}

認証状態管理

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

class AuthGate extends StatefulWidget {
  @override
  _AuthGateState createState() => _AuthGateState();
}

class _AuthGateState extends State<AuthGate> {
  User? _user;
  
  @override
  void initState() {
    super.initState();
    _getInitialSession();
    _listenToAuthChanges();
  }

  Future<void> _getInitialSession() async {
    final session = supabase.auth.currentSession;
    setState(() {
      _user = session?.user;
    });
  }

  void _listenToAuthChanges() {
    supabase.auth.onAuthStateChange.listen((data) {
      final AuthChangeEvent event = data.event;
      final Session? session = data.session;
      
      setState(() {
        _user = session?.user;
      });
      
      switch (event) {
        case AuthChangeEvent.signedIn:
          print('User signed in: ${_user?.email}');
          break;
        case AuthChangeEvent.signedOut:
          print('User signed out');
          break;
        case AuthChangeEvent.passwordRecovery:
          print('Password recovery initiated');
          break;
        case AuthChangeEvent.tokenRefreshed:
          print('Token refreshed');
          break;
        case AuthChangeEvent.userUpdated:
          print('User updated');
          break;
      }
    });
  }

  @override
  Widget build(BuildContext context) {
    return _user == null ? LoginPage() : HomePage();
  }
}

メール認証とパスワード

class EmailPasswordAuth extends StatefulWidget {
  @override
  _EmailPasswordAuthState createState() => _EmailPasswordAuthState();
}

class _EmailPasswordAuthState extends State<EmailPasswordAuth> {
  final _emailController = TextEditingController();
  final _passwordController = TextEditingController();
  bool _isLoading = false;
  String? _errorMessage;

  Future<void> _signUp() async {
    setState(() {
      _isLoading = true;
      _errorMessage = null;
    });

    try {
      final response = await supabase.auth.signUp(
        email: _emailController.text.trim(),
        password: _passwordController.text,
        data: {
          'display_name': 'User Name',
          'avatar_url': 'https://example.com/avatar.jpg',
        },
      );

      if (response.user != null) {
        ScaffoldMessenger.of(context).showSnackBar(
          SnackBar(content: Text('確認メールを送信しました')),
        );
      }
    } on AuthException catch (error) {
      setState(() {
        _errorMessage = error.message;
      });
    } catch (error) {
      setState(() {
        _errorMessage = '予期しないエラーが発生しました';
      });
    } finally {
      setState(() {
        _isLoading = false;
      });
    }
  }

  Future<void> _signIn() async {
    setState(() {
      _isLoading = true;
      _errorMessage = null;
    });

    try {
      final response = await supabase.auth.signInWithPassword(
        email: _emailController.text.trim(),
        password: _passwordController.text,
      );

      if (response.user != null) {
        // ログイン成功 - AuthGateが自動的にHomePageに切り替える
      }
    } on AuthException catch (error) {
      setState(() {
        _errorMessage = error.message;
      });
    } catch (error) {
      setState(() {
        _errorMessage = 'ログインに失敗しました';
      });
    } finally {
      setState(() {
        _isLoading = false;
      });
    }
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text('認証')),
      body: Padding(
        padding: EdgeInsets.all(16.0),
        child: Column(
          children: [
            TextField(
              controller: _emailController,
              decoration: InputDecoration(labelText: 'メールアドレス'),
              keyboardType: TextInputType.emailAddress,
            ),
            TextField(
              controller: _passwordController,
              decoration: InputDecoration(labelText: 'パスワード'),
              obscureText: true,
            ),
            SizedBox(height: 16),
            if (_errorMessage != null)
              Text(_errorMessage!, style: TextStyle(color: Colors.red)),
            SizedBox(height: 16),
            _isLoading
                ? CircularProgressIndicator()
                : Column(
                    children: [
                      ElevatedButton(
                        onPressed: _signUp,
                        child: Text('新規登録'),
                      ),
                      ElevatedButton(
                        onPressed: _signIn,
                        child: Text('ログイン'),
                      ),
                    ],
                  ),
          ],
        ),
      ),
    );
  }
}

OAuth認証(Google、Apple等)

class SocialAuth extends StatelessWidget {
  Future<void> _signInWithGoogle() async {
    try {
      await supabase.auth.signInWithOAuth(
        OAuthProvider.google,
        redirectTo: kIsWeb ? null : 'io.supabase.flutterdemo://callback',
        authScreenLaunchMode: LaunchMode.externalApplication,
      );
    } on AuthException catch (error) {
      ScaffoldMessenger.of(context).showSnackBar(
        SnackBar(content: Text('Google認証エラー: ${error.message}')),
      );
    }
  }

  Future<void> _signInWithApple() async {
    try {
      await supabase.auth.signInWithOAuth(
        OAuthProvider.apple,
        redirectTo: kIsWeb ? null : 'io.supabase.flutterdemo://callback',
      );
    } on AuthException catch (error) {
      ScaffoldMessenger.of(context).showSnackBar(
        SnackBar(content: Text('Apple認証エラー: ${error.message}')),
      );
    }
  }

  Future<void> _signInWithGitHub() async {
    try {
      await supabase.auth.signInWithOAuth(
        OAuthProvider.github,
        redirectTo: kIsWeb ? null : 'io.supabase.flutterdemo://callback',
      );
    } on AuthException catch (error) {
      ScaffoldMessenger.of(context).showSnackBar(
        SnackBar(content: Text('GitHub認証エラー: ${error.message}')),
      );
    }
  }

  @override
  Widget build(BuildContext context) {
    return Column(
      children: [
        ElevatedButton.icon(
          icon: Icon(Icons.account_circle),
          label: Text('Googleでログイン'),
          onPressed: _signInWithGoogle,
          style: ElevatedButton.styleFrom(backgroundColor: Colors.red),
        ),
        ElevatedButton.icon(
          icon: Icon(Icons.apple),
          label: Text('Appleでログイン'),
          onPressed: _signInWithApple,
          style: ElevatedButton.styleFrom(backgroundColor: Colors.black),
        ),
        ElevatedButton.icon(
          icon: Icon(Icons.code),
          label: Text('GitHubでログイン'),
          onPressed: _signInWithGitHub,
          style: ElevatedButton.styleFrom(backgroundColor: Colors.grey[800]),
        ),
      ],
    );
  }
}

マジックリンク認証

class MagicLinkAuth extends StatefulWidget {
  @override
  _MagicLinkAuthState createState() => _MagicLinkAuthState();
}

class _MagicLinkAuthState extends State<MagicLinkAuth> {
  final _emailController = TextEditingController();
  bool _isLoading = false;

  Future<void> _sendMagicLink() async {
    setState(() {
      _isLoading = true;
    });

    try {
      await supabase.auth.signInWithOtp(
        email: _emailController.text.trim(),
        emailRedirectTo: kIsWeb 
          ? null 
          : 'io.supabase.flutterdemo://callback',
      );

      ScaffoldMessenger.of(context).showSnackBar(
        SnackBar(
          content: Text('マジックリンクを送信しました。メールをご確認ください。'),
          backgroundColor: Colors.green,
        ),
      );
    } on AuthException catch (error) {
      ScaffoldMessenger.of(context).showSnackBar(
        SnackBar(
          content: Text('エラー: ${error.message}'),
          backgroundColor: Colors.red,
        ),
      );
    } finally {
      setState(() {
        _isLoading = false;
      });
    }
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text('マジックリンク認証')),
      body: Padding(
        padding: EdgeInsets.all(16.0),
        child: Column(
          children: [
            TextField(
              controller: _emailController,
              decoration: InputDecoration(
                labelText: 'メールアドレス',
                helperText: 'ログイン用のマジックリンクを送信します',
              ),
              keyboardType: TextInputType.emailAddress,
            ),
            SizedBox(height: 16),
            _isLoading
                ? CircularProgressIndicator()
                : ElevatedButton(
                    onPressed: _sendMagicLink,
                    child: Text('マジックリンクを送信'),
                  ),
          ],
        ),
      ),
    );
  }
}

多要素認証(MFA)

class MFASetup extends StatefulWidget {
  @override
  _MFASetupState createState() => _MFASetupState();
}

class _MFASetupState extends State<MFASetup> {
  String? _factorId;
  String? _qrCode;
  String? _secret;
  final _totpController = TextEditingController();

  Future<void> _enrollMFA() async {
    try {
      final response = await supabase.auth.mfa.enroll(
        issuer: 'MyApp',
        friendlyName: 'MyApp MFA',
      );

      setState(() {
        _factorId = response.id;
        _qrCode = response.totp?.qrCode;
        _secret = response.totp?.secret;
      });
    } catch (error) {
      ScaffoldMessenger.of(context).showSnackBar(
        SnackBar(content: Text('MFA登録エラー: $error')),
      );
    }
  }

  Future<void> _verifyMFA() async {
    if (_factorId == null) return;

    try {
      // チャレンジの準備
      final challengeResponse = await supabase.auth.mfa.challenge(
        factorId: _factorId!,
      );

      // TOTPコードの検証
      final verifyResponse = await supabase.auth.mfa.verify(
        factorId: _factorId!,
        challengeId: challengeResponse.id,
        code: _totpController.text,
      );

      ScaffoldMessenger.of(context).showSnackBar(
        SnackBar(
          content: Text('MFA認証成功'),
          backgroundColor: Colors.green,
        ),
      );
    } catch (error) {
      ScaffoldMessenger.of(context).showSnackBar(
        SnackBar(content: Text('MFA認証エラー: $error')),
      );
    }
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text('多要素認証設定')),
      body: Padding(
        padding: EdgeInsets.all(16.0),
        child: Column(
          children: [
            if (_qrCode == null) ...[
              ElevatedButton(
                onPressed: _enrollMFA,
                child: Text('MFA登録開始'),
              ),
            ] else ...[
              Text('認証アプリでQRコードをスキャンしてください:'),
              SizedBox(height: 16),
              // QRコードの表示(qr_flutter パッケージ使用)
              Container(
                width: 200,
                height: 200,
                color: Colors.grey[300],
                child: Center(child: Text('QRコード表示エリア')),
              ),
              SizedBox(height: 16),
              Text('シークレットキー: $_secret'),
              SizedBox(height: 16),
              TextField(
                controller: _totpController,
                decoration: InputDecoration(
                  labelText: '認証アプリの6桁コード',
                ),
                keyboardType: TextInputType.number,
              ),
              SizedBox(height: 16),
              ElevatedButton(
                onPressed: _verifyMFA,
                child: Text('MFA認証'),
              ),
            ],
          ],
        ),
      ),
    );
  }
}

ユーザープロフィール管理

class UserProfile extends StatefulWidget {
  @override
  _UserProfileState createState() => _UserProfileState();
}

class _UserProfileState extends State<UserProfile> {
  final _displayNameController = TextEditingController();
  final _websiteController = TextEditingController();
  bool _isLoading = false;

  @override
  void initState() {
    super.initState();
    _loadUserData();
  }

  void _loadUserData() {
    final user = supabase.auth.currentUser;
    if (user != null) {
      _displayNameController.text = user.userMetadata?['display_name'] ?? '';
      _websiteController.text = user.userMetadata?['website'] ?? '';
    }
  }

  Future<void> _updateProfile() async {
    setState(() {
      _isLoading = true;
    });

    try {
      await supabase.auth.updateUser(
        UserAttributes(
          data: {
            'display_name': _displayNameController.text,
            'website': _websiteController.text,
          },
        ),
      );

      ScaffoldMessenger.of(context).showSnackBar(
        SnackBar(
          content: Text('プロフィールを更新しました'),
          backgroundColor: Colors.green,
        ),
      );
    } on AuthException catch (error) {
      ScaffoldMessenger.of(context).showSnackBar(
        SnackBar(content: Text('更新エラー: ${error.message}')),
      );
    } finally {
      setState(() {
        _isLoading = false;
      });
    }
  }

  Future<void> _signOut() async {
    await supabase.auth.signOut();
  }

  @override
  Widget build(BuildContext context) {
    final user = supabase.auth.currentUser;

    return Scaffold(
      appBar: AppBar(
        title: Text('プロフィール'),
        actions: [
          IconButton(
            icon: Icon(Icons.logout),
            onPressed: _signOut,
          ),
        ],
      ),
      body: Padding(
        padding: EdgeInsets.all(16.0),
        child: Column(
          children: [
            CircleAvatar(
              radius: 50,
              backgroundImage: user?.userMetadata?['avatar_url'] != null
                  ? NetworkImage(user!.userMetadata!['avatar_url'])
                  : null,
              child: user?.userMetadata?['avatar_url'] == null
                  ? Icon(Icons.person, size: 50)
                  : null,
            ),
            SizedBox(height: 16),
            Text('メール: ${user?.email ?? 'N/A'}'),
            SizedBox(height: 16),
            TextField(
              controller: _displayNameController,
              decoration: InputDecoration(labelText: '表示名'),
            ),
            TextField(
              controller: _websiteController,
              decoration: InputDecoration(labelText: 'ウェブサイト'),
            ),
            SizedBox(height: 16),
            _isLoading
                ? CircularProgressIndicator()
                : ElevatedButton(
                    onPressed: _updateProfile,
                    child: Text('プロフィール更新'),
                  ),
          ],
        ),
      ),
    );
  }
}

ディープリンク設定

// android/app/src/main/AndroidManifest.xml
/*
<activity
    android:name=".MainActivity"
    android:exported="true"
    android:launchMode="singleTop"
    android:theme="@style/LaunchTheme">
    
    <!-- Standard App Link -->
    <intent-filter android:autoVerify="true">
        <action android:name="android.intent.action.VIEW" />
        <category android:name="android.intent.category.DEFAULT" />
        <category android:name="android.intent.category.BROWSABLE" />
        <data android:scheme="https"
              android:host="yourdomain.com" />
    </intent-filter>
    
    <!-- Custom URL Scheme -->
    <intent-filter>
        <action android:name="android.intent.action.VIEW" />
        <category android:name="android.intent.category.DEFAULT" />
        <category android:name="android.intent.category.BROWSABLE" />
        <data android:scheme="io.supabase.flutterdemo" />
    </intent-filter>
</activity>
*/

// iOS設定 (ios/Runner/Info.plist)
/*
<dict>
    <key>CFBundleURLTypes</key>
    <array>
        <dict>
            <key>CFBundleURLName</key>
            <string>supabase-auth</string>
            <key>CFBundleURLSchemes</key>
            <array>
                <string>io.supabase.flutterdemo</string>
            </array>
        </dict>
    </array>
</dict>
*/

カスタムローカルストレージ

import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:supabase_flutter/supabase_flutter.dart';

class SecureLocalStorage extends LocalStorage {
  final FlutterSecureStorage _storage = FlutterSecureStorage();

  @override
  Future<void> initialize() async {}

  @override
  Future<String?> accessToken() async {
    return await _storage.read(key: supabasePersistSessionKey);
  }

  @override
  Future<bool> hasAccessToken() async {
    return await _storage.containsKey(key: supabasePersistSessionKey);
  }

  @override
  Future<void> persistSession(String persistSessionString) async {
    await _storage.write(
      key: supabasePersistSessionKey,
      value: persistSessionString,
    );
  }

  @override
  Future<void> removePersistedSession() async {
    await _storage.delete(key: supabasePersistSessionKey);
  }
}

// 使用例
await Supabase.initialize(
  url: 'YOUR_SUPABASE_URL',
  anonKey: 'YOUR_SUPABASE_ANON_KEY',
  authOptions: FlutterAuthClientOptions(
    localStorage: SecureLocalStorage(),
  ),
);