misskey_auth 0.1.4-beta copy "misskey_auth: ^0.1.4-beta" to clipboard
misskey_auth: ^0.1.4-beta copied to clipboard

Flutter library for Misskey OAuth authentication. Support for MiAuth format is planned in the future.

example/lib/main.dart

import 'dart:developer' as developer;
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:misskey_auth/misskey_auth.dart';
import 'package:loader_overlay/loader_overlay.dart';
import 'package:flutter/services.dart';

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

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

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Misskey Auth Example',
      theme: ThemeData(
        colorScheme: ColorScheme.fromSeed(seedColor: Colors.blue),
        useMaterial3: true,
      ),
      home: LoaderOverlay(
        child: const AuthExamplePage(),
      ),
    );
  }
}

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

  @override
  State<AuthExamplePage> createState() => _AuthExamplePageState();
}

class _AuthExamplePageState extends State<AuthExamplePage> {
  final _auth = MisskeyAuthManager.defaultInstance();
  final _oauthClient = MisskeyOAuthClient(); // サーバー情報の確認用
  int _currentIndex = 0;

  // フォームコントローラー
  final _hostController = TextEditingController();
  final _clientIdController = TextEditingController();
  final _redirectUriController = TextEditingController();
  final _scopeController = TextEditingController();
  final _callbackSchemeController = TextEditingController();

  // MiAuth 用フォーム
  final _miAppNameController = TextEditingController();
  final _miPermissionsController = TextEditingController();
  final _miIconUrlController = TextEditingController();

  // 状態
  OAuthServerInfo? _serverInfo;

  // スコープ入力(カスタムのみを採用)
  final TextEditingController _oauthCustomScopesController =
      TextEditingController();
  final TextEditingController _miCustomScopesController =
      TextEditingController();

  void _addOAuthCustomScopesFromInput() {
    final List<String> items = _oauthCustomScopesController.text
        .split(',')
        .map((e) => e.trim())
        .where((e) => e.isNotEmpty)
        .toList();
    if (items.isEmpty) return;
    _scopeController.text = items.join(' ');
    setState(() {
      _oauthCustomScopesController.clear();
    });
  }

  void _addMiCustomScopesFromInput() {
    final List<String> items = _miCustomScopesController.text
        .split(',')
        .map((e) => e.trim())
        .where((e) => e.isNotEmpty)
        .toList();
    if (items.isEmpty) return;
    _miPermissionsController.text = items.join(' ');
    setState(() {
      _miCustomScopesController.clear();
    });
  }

  String _mapErrorToMessage(Object error) {
    // MisskeyAuth のカスタム例外をユーザー向け日本語に整形
    if (error is MisskeyAuthException) {
      final details = error.details;
      if (error is UserCancelledException) {
        return '認証がキャンセルされました';
      }
      if (error is CallbackSchemeErrorException) {
        return 'コールバックスキームの設定が正しくありません(AndroidManifest/Info.plist を確認してください)';
      }
      if (error is AuthorizationLaunchException) {
        return '認証画面を起動できませんでした';
      }
      if (error is NetworkException) {
        return 'ネットワークエラーが発生しました';
      }
      if (error is ResponseParseException) {
        return 'サーバー応答の解析に失敗しました';
      }
      if (error is SecureStorageException) {
        return 'セキュアストレージの操作に失敗しました';
      }
      if (error is InvalidAuthConfigException) {
        return '認証設定が無効です';
      }
      if (error is ServerInfoException) {
        return 'サーバー情報の取得に失敗しました${details != null ? ': $details' : ''}';
      }
      // OAuth
      if (error is OAuthNotSupportedException) {
        return 'このサーバーはOAuth認証に対応していません(MiAuthをご利用ください)';
      }
      if (error is StateMismatchException) {
        return 'セキュリティ検証に失敗しました(state不一致)';
      }
      if (error is AuthorizationCodeMissingException) {
        return '認証コードを取得できませんでした';
      }
      if (error is AuthorizationServerErrorException) {
        return '認可サーバーでエラーが発生しました${details != null ? ': $details' : ''}';
      }
      if (error is TokenExchangeException) {
        return 'トークン交換に失敗しました${details != null ? ': $details' : ''}';
      }
      // MiAuth
      if (error is MiAuthDeniedException) {
        return 'MiAuth がキャンセル/拒否されました';
      }
      if (error is MiAuthCheckFailedException) {
        return 'MiAuth の検証に失敗しました${details != null ? ': $details' : ''}';
      }
      if (error is MiAuthSessionInvalidException) {
        return 'MiAuth のセッションが無効または期限切れです${details != null ? ': $details' : ''}';
      }
      return error.toString();
    }
    // その他の例外はそのまま文字列化
    return error.toString();
  }

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

  @override
  void dispose() {
    _hostController.dispose();
    _clientIdController.dispose();
    _redirectUriController.dispose();
    _scopeController.dispose();
    _callbackSchemeController.dispose();
    _miAppNameController.dispose();
    _miPermissionsController.dispose();
    _miIconUrlController.dispose();
    super.dispose();
  }

  void _setDefaultValues() {
    _hostController.text = 'misskey.io';
    _clientIdController.text =
        'https://librarylibrarian.github.io/misskey_auth/';
    _redirectUriController.text =
        'https://librarylibrarian.github.io/misskey_auth/redirect.html';
    _scopeController.text = 'read:account write:notes';
    _callbackSchemeController.text = 'misskeyauth';

    // MiAuth
    _miAppNameController.text = 'Misskey Auth Example';
    _miPermissionsController.text = 'read:account write:notes';
    _miIconUrlController.text = '';

    // 候補配列は廃止(カスタム欄から確定時にTextControllerへ反映)
  }

  Future<void> _checkServerInfo() async {
    setState(() {
      _serverInfo = null;
    });

    if (!mounted) return;
    context.loaderOverlay.show();

    try {
      final host = _hostController.text.trim();
      if (host.isEmpty) {
        throw Exception('ホストを入力してください');
      }

      final serverInfo = await _oauthClient.getOAuthServerInfo(host);

      if (!mounted) return;
      setState(() {
        _serverInfo = serverInfo;
      });

      if (serverInfo == null && mounted) {
        ScaffoldMessenger.of(context)
          ..hideCurrentSnackBar()
          ..showSnackBar(
            const SnackBar(
                content: Text('OAuth認証はサポートされていません(MiAuth認証を使用してください)')),
          );
      }
    } on MisskeyAuthException catch (e) {
      if (mounted) {
        ScaffoldMessenger.of(context)
          ..hideCurrentSnackBar()
          ..showSnackBar(SnackBar(content: Text(_mapErrorToMessage(e))));
      }
    } catch (e) {
      if (mounted) {
        ScaffoldMessenger.of(context)
          ..hideCurrentSnackBar()
          ..showSnackBar(SnackBar(content: Text(e.toString())));
      }
    } finally {
      if (mounted) {
        context.loaderOverlay.hide();
      }
    }
  }

  Future<void> _startAuth() async {
    if (!mounted) return;
    context.loaderOverlay.show();

    try {
      // 未確定のカスタムスコープ入力を確定して反映
      _addOAuthCustomScopesFromInput();
      final config = MisskeyOAuthConfig(
        host: _hostController.text.trim(),
        clientId: _clientIdController.text.trim(),
        redirectUri: _redirectUriController.text.trim(),
        scope: _scopeController.text.trim(),
        callbackScheme: _callbackSchemeController.text.trim(),
      );

      final key = await _auth.loginWithOAuth(config, setActive: true);
      if (kDebugMode) {
        final t = await _auth.tokenOf(key);
        developer
            .log('[OAuth] account=${key.accountId} token=${t?.accessToken}');
      }

      if (mounted) {
        ScaffoldMessenger.of(context)
          ..hideCurrentSnackBar()
          ..showSnackBar(const SnackBar(content: Text('認証に成功しました!')));
        setState(() {
          _currentIndex = 3; // アカウント一覧タブへ
        });
      }
    } on MisskeyAuthException catch (e) {
      if (mounted) {
        ScaffoldMessenger.of(context)
          ..hideCurrentSnackBar()
          ..showSnackBar(SnackBar(content: Text(_mapErrorToMessage(e))));
      }
    } catch (e) {
      if (mounted) {
        ScaffoldMessenger.of(context)
          ..hideCurrentSnackBar()
          ..showSnackBar(SnackBar(content: Text('認証エラー: $e')));
      }
    } finally {
      if (mounted) {
        context.loaderOverlay.hide();
      }
    }
  }

  Future<void> _startMiAuth() async {
    if (!mounted) return;
    context.loaderOverlay.show();

    try {
      // 未確定のカスタムスコープ入力を確定して反映
      _addMiCustomScopesFromInput();
      final host = _hostController.text.trim();
      if (host.isEmpty) {
        throw Exception('ホストを入力してください');
      }

      final scheme = _callbackSchemeController.text.trim();
      if (scheme.isEmpty) {
        throw Exception('コールバックスキームを入力してください');
      }

      final permissions = _miPermissionsController.text
          .split(RegExp(r"[ ,]+"))
          .where((e) => e.isNotEmpty)
          .toList();

      final config = MisskeyMiAuthConfig(
        host: host,
        appName: _miAppNameController.text.trim(),
        callbackScheme: scheme,
        permissions: permissions,
        iconUrl: _miIconUrlController.text.trim().isEmpty
            ? null
            : _miIconUrlController.text.trim(),
      );

      final key = await _auth.loginWithMiAuth(config, setActive: true);
      if (kDebugMode) {
        final t = await _auth.tokenOf(key);
        developer
            .log('[MiAuth] account=${key.accountId} token=${t?.accessToken}');
      }

      if (mounted) {
        ScaffoldMessenger.of(context)
          ..hideCurrentSnackBar()
          ..showSnackBar(const SnackBar(content: Text('MiAuth に成功しました!')));
        setState(() {
          _currentIndex = 3; // アカウント一覧タブへ
        });
      }
    } on MisskeyAuthException catch (e) {
      if (mounted) {
        ScaffoldMessenger.of(context)
          ..hideCurrentSnackBar()
          ..showSnackBar(SnackBar(content: Text(_mapErrorToMessage(e))));
      }
    } catch (e) {
      if (mounted) {
        ScaffoldMessenger.of(context)
          ..hideCurrentSnackBar()
          ..showSnackBar(SnackBar(content: Text('MiAuth エラー: $e')));
      }
    } finally {
      if (mounted) {
        context.loaderOverlay.hide();
      }
    }
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        backgroundColor: Theme.of(context).colorScheme.inversePrimary,
        title: const Text('Misskey Auth Sample'),
      ),
      body: SingleChildScrollView(
        padding: const EdgeInsets.all(16.0),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.stretch,
          children: [
            if (_currentIndex == 0)
              _buildOAuthForm(context)
            else if (_currentIndex == 1)
              _buildMiAuthForm(context)
            else if (_currentIndex == 2)
              _buildServerInfoTab(context)
            else
              _buildAccountsTab(context),
          ],
        ),
      ),
      bottomNavigationBar: NavigationBar(
        selectedIndex: _currentIndex,
        destinations: const [
          NavigationDestination(icon: Icon(Icons.lock), label: 'OAuth'),
          NavigationDestination(icon: Icon(Icons.vpn_key), label: 'MiAuth'),
          NavigationDestination(
              icon: Icon(Icons.info_outline), label: 'サーバー情報'),
          NavigationDestination(icon: Icon(Icons.people), label: 'アカウント一覧'),
        ],
        onDestinationSelected: (index) {
          setState(() {
            _currentIndex = index;
          });
        },
      ),
    );
  }

  Widget _buildOAuthForm(BuildContext context) {
    return Card(
      child: Padding(
        padding: const EdgeInsets.all(16.0),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            const Text(
              'OAuth認証設定',
              style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
            ),
            const SizedBox(height: 8),
            TextField(
              controller: _callbackSchemeController,
              decoration: const InputDecoration(
                labelText: 'コールバックスキーム',
                hintText: '例: misskeyauth',
              ),
            ),
            const SizedBox(height: 8),
            TextField(
              controller: _hostController,
              decoration: const InputDecoration(
                labelText: 'ホスト',
                hintText: '例: misskey.io',
              ),
            ),
            const SizedBox(height: 8),
            TextField(
              controller: _clientIdController,
              decoration: const InputDecoration(
                labelText: 'クライアントID (URL)',
                hintText: '例: https://example.com/my-app',
              ),
            ),
            const SizedBox(height: 8),
            TextField(
              controller: _redirectUriController,
              decoration: const InputDecoration(
                labelText: 'リダイレクトURI',
                hintText: '例: https://example.com/redirect',
                helperText: '要HTTPS',
              ),
            ),
            const SizedBox(height: 8),
            const Text(
              'カスタムスコープ(カンマ区切り)',
              style: TextStyle(fontWeight: FontWeight.w600),
            ),
            const SizedBox(height: 8),
            TextField(
              controller: _oauthCustomScopesController,
              decoration: const InputDecoration(
                labelText: '例: write:drive, read:favorites',
              ),
              keyboardType: TextInputType.text,
              textInputAction: TextInputAction.done,
              onSubmitted: (_) => _addOAuthCustomScopesFromInput(),
            ),
            const SizedBox(height: 16),
            SizedBox(
              width: double.infinity,
              child: ElevatedButton(
                onPressed: _startAuth,
                style: ElevatedButton.styleFrom(
                  backgroundColor: Theme.of(context).colorScheme.primary,
                  foregroundColor: Colors.white,
                ),
                child: const Text('OAuthで認証'),
              ),
            ),
          ],
        ),
      ),
    );
  }

  Widget _buildMiAuthForm(BuildContext context) {
    return Card(
      child: Padding(
        padding: const EdgeInsets.all(16.0),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            const Text(
              'MiAuth認証設定',
              style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
            ),
            const SizedBox(height: 8),
            TextField(
              controller: _callbackSchemeController,
              decoration: const InputDecoration(
                labelText: 'コールバックスキーム',
                hintText: '例: misskeyauth',
              ),
            ),
            const SizedBox(height: 8),
            TextField(
              controller: _hostController,
              decoration: const InputDecoration(
                labelText: 'ホスト',
                hintText: '例: misskey.io',
              ),
            ),
            const SizedBox(height: 8),
            TextField(
              controller: _miAppNameController,
              decoration: const InputDecoration(
                labelText: 'アプリ名',
                hintText: '例: Misskey Auth Example',
              ),
            ),
            const SizedBox(height: 8),
            const Text(
              'カスタムスコープ(カンマ区切り)',
              style: TextStyle(fontWeight: FontWeight.w600),
            ),
            const SizedBox(height: 8),
            TextField(
              controller: _miCustomScopesController,
              decoration: const InputDecoration(
                labelText: '例: write:drive, read:favorites',
              ),
              keyboardType: TextInputType.text,
              textInputAction: TextInputAction.done,
              onSubmitted: (_) => _addMiCustomScopesFromInput(),
            ),
            const SizedBox(height: 8),
            TextField(
              controller: _miIconUrlController,
              decoration: const InputDecoration(
                labelText: 'アイコンURL(任意)',
              ),
            ),
            const SizedBox(height: 16),
            SizedBox(
              width: double.infinity,
              child: ElevatedButton(
                onPressed: _startMiAuth,
                style: ElevatedButton.styleFrom(
                  backgroundColor: Theme.of(context).colorScheme.primary,
                  foregroundColor: Colors.white,
                ),
                child: const Text('MiAuthで認証'),
              ),
            ),
          ],
        ),
      ),
    );
  }

  Widget _buildServerInfoCard() {
    return Card(
      child: Padding(
        padding: const EdgeInsets.all(16.0),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            const Text(
              'サーバー情報',
              style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
            ),
            const SizedBox(height: 8),
            const Text('認証エンドポイント'),
            const SizedBox(height: 4),
            SelectableText(_serverInfo!.authorizationEndpoint),
            const SizedBox(height: 8),
            const Text('トークンエンドポイント'),
            const SizedBox(height: 4),
            SelectableText(_serverInfo!.tokenEndpoint),
            if (_serverInfo!.scopesSupported != null &&
                _serverInfo!.scopesSupported!.isNotEmpty) ...[
              const SizedBox(height: 12),
              const Text('サポートされているスコープ(タップでコピー)'),
              const SizedBox(height: 4),
              ConstrainedBox(
                constraints: const BoxConstraints(maxHeight: 200),
                child: Scrollbar(
                  child: ListView.separated(
                    itemCount: _serverInfo!.scopesSupported!.length,
                    separatorBuilder: (_, __) => const Divider(height: 1),
                    itemBuilder: (context, index) {
                      final scope = _serverInfo!.scopesSupported![index];
                      return InkWell(
                        onTap: () async {
                          await Clipboard.setData(ClipboardData(text: scope));
                          if (!context.mounted) return;
                          ScaffoldMessenger.of(context)
                            ..hideCurrentSnackBar()
                            ..showSnackBar(
                              SnackBar(content: Text('コピーしました: $scope')),
                            );
                        },
                        child: Padding(
                          padding: const EdgeInsets.symmetric(vertical: 10.0),
                          child: Text(scope),
                        ),
                      );
                    },
                  ),
                ),
              ),
            ],
          ],
        ),
      ),
    );
  }

  Widget _buildServerInfoTab(BuildContext context) {
    return Column(
      crossAxisAlignment: CrossAxisAlignment.stretch,
      children: [
        Card(
          child: Padding(
            padding: const EdgeInsets.all(16.0),
            child: Column(
              crossAxisAlignment: CrossAxisAlignment.start,
              children: [
                const Text(
                  'サーバー情報の確認',
                  style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
                ),
                const SizedBox(height: 8),
                TextField(
                  controller: _hostController,
                  decoration: const InputDecoration(
                    labelText: 'ホスト',
                    hintText: '例: misskey.io',
                  ),
                ),
                const SizedBox(height: 16),
                SizedBox(
                  width: double.infinity,
                  child: ElevatedButton(
                    onPressed: _checkServerInfo,
                    child: const Text('サーバー情報を確認'),
                  ),
                ),
              ],
            ),
          ),
        ),
        if (_serverInfo != null) ...[
          const SizedBox(height: 16),
          _buildServerInfoCard(),
        ],
      ],
    );
  }

  Widget _buildAccountsTab(BuildContext context) {
    return Card(
      child: Padding(
        padding: const EdgeInsets.all(16.0),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            Row(
              children: [
                const Expanded(
                  child: Text(
                    'ログイン済みアカウント',
                    style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
                  ),
                ),
                IconButton(
                  icon: const Icon(Icons.refresh),
                  tooltip: '再読込',
                  onPressed: () {
                    setState(() {}); // FutureBuilder を再評価
                  },
                ),
                IconButton(
                  icon: const Icon(Icons.bug_report),
                  tooltip: 'デバッグログにトークンを出力',
                  onPressed: () async {
                    if (!kDebugMode) return;
                    final accounts = await _auth.listAccounts();
                    for (final entry in accounts) {
                      final key = entry.key;
                      final t = await _auth.tokenOf(key);
                      developer.log(
                          '[Dump] ${key.host}/${key.accountId} token=${t?.accessToken}');
                    }
                    if (!context.mounted) return;
                    ScaffoldMessenger.of(context)
                      ..hideCurrentSnackBar()
                      ..showSnackBar(
                        const SnackBar(content: Text('デバッグログにトークンを出力しました')),
                      );
                  },
                )
              ],
            ),
            const SizedBox(height: 8),
            FutureBuilder<List<Object?>>(
              future: Future.wait<Object?>([
                _auth.listAccounts(),
                _auth.getActive(),
              ]),
              builder: (context, snapshot) {
                if (snapshot.connectionState == ConnectionState.waiting) {
                  return const Center(
                      child: Padding(
                    padding: EdgeInsets.all(16.0),
                    child: CircularProgressIndicator(),
                  ));
                }
                if (!snapshot.hasData) {
                  return const Text('アカウント情報を取得できませんでした');
                }
                final accounts = (snapshot.data![0] as List<AccountEntry>);
                final active = snapshot.data![1] as AccountKey?;
                if (accounts.isEmpty) {
                  return const Text('ログイン済みのアカウントはありません');
                }
                return ListView.separated(
                  shrinkWrap: true,
                  physics: const NeverScrollableScrollPhysics(),
                  itemCount: accounts.length,
                  separatorBuilder: (_, __) => const Divider(height: 1),
                  itemBuilder: (context, index) {
                    final entry = accounts[index];
                    final key = entry.key;
                    final isActive = active != null && active == key;
                    final title = entry.userName ?? key.accountId;
                    final saved = entry.createdAt != null
                        ? '保存: ${entry.createdAt!.toLocal().toString().substring(0, 19)}'
                        : null;
                    return ListTile(
                      leading: Icon(
                          isActive ? Icons.star : Icons.person_outline,
                          color: isActive ? Colors.amber : null),
                      title: Text(title),
                      subtitle: Text(
                          '${key.host} / ${key.accountId}${saved != null ? '\n$saved' : ''}'),
                      isThreeLine: saved != null,
                      trailing: IconButton(
                        icon: const Icon(Icons.delete, color: Colors.red),
                        onPressed: () async {
                          await _auth.signOut(key);
                          if (mounted) setState(() {});
                        },
                        tooltip: 'このアカウントを削除',
                      ),
                      onTap: () async {
                        await _auth.setActive(key);
                        if (mounted) setState(() {});
                        if (!context.mounted) return;
                        ScaffoldMessenger.of(context)
                          ..hideCurrentSnackBar()
                          ..showSnackBar(
                            SnackBar(
                                content: Text('デフォルトを変更: ${key.accountId}')),
                          );
                      },
                    );
                  },
                );
              },
            ),
          ],
        ),
      ),
    );
  }
}
0
likes
160
points
37
downloads

Publisher

verified publisherlibrarylibrarian.com

Weekly Downloads

Flutter library for Misskey OAuth authentication. Support for MiAuth format is planned in the future.

Homepage
Repository (GitHub)
View/report issues

Topics

#authentication #oauth #misskey #social-media #api

Documentation

API reference

License

BSD-3-Clause (license)

Dependencies

crypto, cupertino_icons, dio, flutter, flutter_secure_storage, flutter_web_auth_2

More

Packages that depend on misskey_auth