From 364e93424b393422a514a5cf089387dd6a2dff81 Mon Sep 17 00:00:00 2001 From: librarian <57712678+LibraryLibrarian@users.noreply.github.com> Date: Sat, 16 Aug 2025 03:27:12 +0900 Subject: [PATCH 1/4] =?UTF-8?q?feat!:=20Misskey=E8=AA=8D=E8=A8=BC=E3=83=9E?= =?UTF-8?q?=E3=83=8D=E3=83=BC=E3=82=B8=E3=83=A3=E3=83=BC=E3=81=AE=E5=AE=9F?= =?UTF-8?q?=E8=A3=85=E3=81=A8=E3=83=88=E3=83=BC=E3=82=AF=E3=83=B3=E7=AE=A1?= =?UTF-8?q?=E7=90=86=E3=81=AE=E6=94=B9=E5=96=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - MisskeyAuthManagerを追加し、MiAuthおよびOAuthの認証を統合管理 - トークンの保存・取得・削除をTokenStoreインターフェースを通じて実装 - 不要なストレージ操作を削除し、クリーンなコードに改善 - UIの更新に伴い、アカウント一覧表示機能を追加 --- example/lib/main.dart | 241 ++++++++++++++---------- lib/main.dart | 122 ------------ lib/misskey_auth.dart | 7 + lib/src/api/misskey_miauth_client.dart | 64 +++---- lib/src/api/misskey_oauth_client.dart | 93 +++------ lib/src/auth/misskey_auth_manager.dart | 155 +++++++++++++++ lib/src/models/miauth_models.dart | 2 +- lib/src/models/misskey_server_info.dart | 69 +++++++ lib/src/models/oauth_models.dart | 5 - lib/src/store/account_key.dart | 45 +++++ lib/src/store/secure_token_store.dart | 142 ++++++++++++++ lib/src/store/stored_token.dart | 61 ++++++ lib/src/store/token_store.dart | 29 +++ test/widget_test.dart | 24 --- 14 files changed, 700 insertions(+), 359 deletions(-) delete mode 100644 lib/main.dart create mode 100644 lib/src/auth/misskey_auth_manager.dart create mode 100644 lib/src/models/misskey_server_info.dart create mode 100644 lib/src/store/account_key.dart create mode 100644 lib/src/store/secure_token_store.dart create mode 100644 lib/src/store/stored_token.dart create mode 100644 lib/src/store/token_store.dart diff --git a/example/lib/main.dart b/example/lib/main.dart index c2a1e7c..377160f 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -1,3 +1,5 @@ +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'; @@ -32,8 +34,8 @@ class AuthExamplePage extends StatefulWidget { } class _AuthExamplePageState extends State { - final _client = MisskeyOAuthClient(); - final _miClient = MisskeyMiAuthClient(); + final _auth = MisskeyAuthManager.defaultInstance(); + final _oauthClient = MisskeyOAuthClient(); // サーバー情報の確認用 int _currentIndex = 0; // フォームコントローラー @@ -49,7 +51,6 @@ class _AuthExamplePageState extends State { final _miIconUrlController = TextEditingController(); // 状態 - String? _accessToken; OAuthServerInfo? _serverInfo; String _mapErrorToMessage(Object error) { @@ -116,7 +117,6 @@ class _AuthExamplePageState extends State { void initState() { super.initState(); _setDefaultValues(); - _loadStoredToken(); } @override @@ -147,21 +147,6 @@ class _AuthExamplePageState extends State { _miIconUrlController.text = ''; } - Future _loadStoredToken() async { - try { - final token = await _client.getStoredAccessToken(); - setState(() { - _accessToken = token; - }); - } on MisskeyAuthException catch (e) { - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(_mapErrorToMessage(e))), - ); - } - } catch (_) {} - } - Future _checkServerInfo() async { setState(() { _serverInfo = null; @@ -176,7 +161,7 @@ class _AuthExamplePageState extends State { throw Exception('ホストを入力してください'); } - final serverInfo = await _client.getOAuthServerInfo(host); + final serverInfo = await _oauthClient.getOAuthServerInfo(host); if (!mounted) return; setState(() { @@ -221,18 +206,20 @@ class _AuthExamplePageState extends State { callbackScheme: _callbackSchemeController.text.trim(), ); - final tokenResponse = await _client.authenticate(config); + 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 (tokenResponse != null && mounted) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('認証に成功しました!')), + ); setState(() { - _accessToken = tokenResponse.accessToken; + _currentIndex = 3; // アカウント一覧タブへ }); - - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('認証に成功しました!')), - ); - } } } on MisskeyAuthException catch (e) { if (mounted) { @@ -283,18 +270,20 @@ class _AuthExamplePageState extends State { : _miIconUrlController.text.trim(), ); - final res = await _miClient.authenticate(config); + 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).showSnackBar( + const SnackBar(content: Text('MiAuth に成功しました!')), + ); setState(() { - _accessToken = res.token; + _currentIndex = 3; // アカウント一覧タブへ }); - - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('MiAuth に成功しました!')), - ); - } } } on MisskeyAuthException catch (e) { if (mounted) { @@ -315,32 +304,6 @@ class _AuthExamplePageState extends State { } } - Future _clearToken() async { - if (!mounted) return; - context.loaderOverlay.show(); - - try { - await _client.clearTokens(); - await _loadStoredToken(); - - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('トークンを削除しました')), - ); - } - } on MisskeyAuthException catch (e) { - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(_mapErrorToMessage(e))), - ); - } - } finally { - if (mounted) { - context.loaderOverlay.hide(); - } - } - } - @override Widget build(BuildContext context) { return Scaffold( @@ -353,15 +316,14 @@ class _AuthExamplePageState extends State { child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - _buildStoredTokenCard(), - const SizedBox(height: 16), if (_currentIndex == 0) _buildOAuthForm(context) else if (_currentIndex == 1) _buildMiAuthForm(context) + else if (_currentIndex == 2) + _buildServerInfoTab(context) else - _buildServerInfoTab(context), - // 画面内のエラーカード表示は行わず、Snackbarのみで通知 + _buildAccountsTab(context), ], ), ), @@ -372,6 +334,7 @@ class _AuthExamplePageState extends State { NavigationDestination(icon: Icon(Icons.vpn_key), label: 'MiAuth'), NavigationDestination( icon: Icon(Icons.info_outline), label: 'サーバー情報'), + NavigationDestination(icon: Icon(Icons.people), label: 'アカウント一覧'), ], onDestinationSelected: (index) { setState(() { @@ -382,38 +345,6 @@ class _AuthExamplePageState extends State { ); } - Widget _buildStoredTokenCard() { - 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), - Text(_accessToken != null - ? '${_accessToken!.substring(0, 10)}...' - : 'トークンなし'), - if (_accessToken != null) ...[ - const SizedBox(height: 8), - ElevatedButton( - onPressed: _clearToken, - style: ElevatedButton.styleFrom( - backgroundColor: Colors.red, - foregroundColor: Colors.white, - ), - child: const Text('トークンを削除'), - ), - ], - ], - ), - ), - ); - } - Widget _buildOAuthForm(BuildContext context) { return Card( child: Padding( @@ -619,5 +550,115 @@ class _AuthExamplePageState extends State { ); } - // 画面内のエラーカードは廃止(Snackbarのみ使用) + 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).showSnackBar( + const SnackBar(content: Text('デバッグログにトークンを出力しました')), + ); + }, + ) + ], + ), + const SizedBox(height: 8), + FutureBuilder>( + future: Future.wait([ + _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); + 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).showSnackBar( + SnackBar(content: Text('デフォルトを変更: ${key.accountId}')), + ); + }, + ); + }, + ); + }, + ), + ], + ), + ), + ); + } } diff --git a/lib/main.dart b/lib/main.dart deleted file mode 100644 index 7b7f5b6..0000000 --- a/lib/main.dart +++ /dev/null @@ -1,122 +0,0 @@ -import 'package:flutter/material.dart'; - -void main() { - runApp(const MyApp()); -} - -class MyApp extends StatelessWidget { - const MyApp({super.key}); - - // This widget is the root of your application. - @override - Widget build(BuildContext context) { - return MaterialApp( - title: 'Flutter Demo', - theme: ThemeData( - // This is the theme of your application. - // - // TRY THIS: Try running your application with "flutter run". You'll see - // the application has a purple toolbar. Then, without quitting the app, - // try changing the seedColor in the colorScheme below to Colors.green - // and then invoke "hot reload" (save your changes or press the "hot - // reload" button in a Flutter-supported IDE, or press "r" if you used - // the command line to start the app). - // - // Notice that the counter didn't reset back to zero; the application - // state is not lost during the reload. To reset the state, use hot - // restart instead. - // - // This works for code too, not just values: Most code changes can be - // tested with just a hot reload. - colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple), - ), - home: const MyHomePage(title: 'Flutter Demo Home Page'), - ); - } -} - -class MyHomePage extends StatefulWidget { - const MyHomePage({super.key, required this.title}); - - // This widget is the home page of your application. It is stateful, meaning - // that it has a State object (defined below) that contains fields that affect - // how it looks. - - // This class is the configuration for the state. It holds the values (in this - // case the title) provided by the parent (in this case the App widget) and - // used by the build method of the State. Fields in a Widget subclass are - // always marked "final". - - final String title; - - @override - State createState() => _MyHomePageState(); -} - -class _MyHomePageState extends State { - int _counter = 0; - - void _incrementCounter() { - setState(() { - // This call to setState tells the Flutter framework that something has - // changed in this State, which causes it to rerun the build method below - // so that the display can reflect the updated values. If we changed - // _counter without calling setState(), then the build method would not be - // called again, and so nothing would appear to happen. - _counter++; - }); - } - - @override - Widget build(BuildContext context) { - // This method is rerun every time setState is called, for instance as done - // by the _incrementCounter method above. - // - // The Flutter framework has been optimized to make rerunning build methods - // fast, so that you can just rebuild anything that needs updating rather - // than having to individually change instances of widgets. - return Scaffold( - appBar: AppBar( - // TRY THIS: Try changing the color here to a specific color (to - // Colors.amber, perhaps?) and trigger a hot reload to see the AppBar - // change color while the other colors stay the same. - backgroundColor: Theme.of(context).colorScheme.inversePrimary, - // Here we take the value from the MyHomePage object that was created by - // the App.build method, and use it to set our appbar title. - title: Text(widget.title), - ), - body: Center( - // Center is a layout widget. It takes a single child and positions it - // in the middle of the parent. - child: Column( - // Column is also a layout widget. It takes a list of children and - // arranges them vertically. By default, it sizes itself to fit its - // children horizontally, and tries to be as tall as its parent. - // - // Column has various properties to control how it sizes itself and - // how it positions its children. Here we use mainAxisAlignment to - // center the children vertically; the main axis here is the vertical - // axis because Columns are vertical (the cross axis would be - // horizontal). - // - // TRY THIS: Invoke "debug painting" (choose the "Toggle Debug Paint" - // action in the IDE, or press "p" in the console), to see the - // wireframe for each widget. - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const Text('You have pushed the button this many times:'), - Text( - '$_counter', - style: Theme.of(context).textTheme.headlineMedium, - ), - ], - ), - ), - floatingActionButton: FloatingActionButton( - onPressed: _incrementCounter, - tooltip: 'Increment', - child: const Icon(Icons.add), - ), // This trailing comma makes auto-formatting nicer for build methods. - ); - } -} diff --git a/lib/misskey_auth.dart b/lib/misskey_auth.dart index d6f9fea..e254d5a 100644 --- a/lib/misskey_auth.dart +++ b/lib/misskey_auth.dart @@ -8,3 +8,10 @@ export 'src/api/misskey_miauth_client.dart'; // Exceptions export 'src/exceptions/misskey_auth_exception.dart'; + +// Store & Manager +export 'src/store/account_key.dart'; +export 'src/store/stored_token.dart'; +export 'src/store/token_store.dart'; +export 'src/store/secure_token_store.dart'; +export 'src/auth/misskey_auth_manager.dart'; diff --git a/lib/src/api/misskey_miauth_client.dart b/lib/src/api/misskey_miauth_client.dart index 0287c0b..695a590 100644 --- a/lib/src/api/misskey_miauth_client.dart +++ b/lib/src/api/misskey_miauth_client.dart @@ -2,7 +2,6 @@ import 'dart:math'; import 'package:dio/dio.dart'; import 'package:flutter/foundation.dart'; -import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:flutter/services.dart'; import 'package:flutter_web_auth_2/flutter_web_auth_2.dart'; @@ -12,17 +11,30 @@ import '../exceptions/misskey_auth_exception.dart'; /// Misskey の MiAuth 認証を扱うクライアント class MisskeyMiAuthClient { final Dio _dio; - final FlutterSecureStorage _storage; - - // ストレージキー(OAuth 実装と揃える) - static const _accessTokenKey = 'misskey_access_token'; - static const _hostKey = 'misskey_host'; + /// 認証通信で使用するHTTPクライアント + /// + /// [dio] を渡さない場合は、次のデフォルトタイムアウトで初期化 + /// - 接続: 10秒 + /// - 送信: 20秒 + /// - 受信: 20秒 MisskeyMiAuthClient({ Dio? dio, - FlutterSecureStorage? storage, - }) : _dio = dio ?? Dio(), - _storage = storage ?? const FlutterSecureStorage(); + Duration? connectTimeout, + Duration? sendTimeout, + Duration? receiveTimeout, + }) : _dio = dio ?? + Dio(BaseOptions( + connectTimeout: connectTimeout ?? const Duration(seconds: 10), + sendTimeout: sendTimeout ?? const Duration(seconds: 20), + receiveTimeout: receiveTimeout ?? const Duration(seconds: 20), + )) { + if (dio != null) { + if (connectTimeout != null) _dio.options.connectTimeout = connectTimeout; + if (sendTimeout != null) _dio.options.sendTimeout = sendTimeout; + if (receiveTimeout != null) _dio.options.receiveTimeout = receiveTimeout; + } + } /// ランダムなセッション ID を生成(URL セーフな英数字) String generateSessionId({int length = 32}) { @@ -128,20 +140,8 @@ class MisskeyMiAuthClient { throw const MiAuthDeniedException(); } - // 5. トークン保存 - try { - await _storage.write(key: _hostKey, value: config.host); - await _storage.write(key: _accessTokenKey, value: check.token); - } on PlatformException catch (e) { - throw SecureStorageException(details: e.message, originalException: e); - } catch (e) { - if (e is MisskeyAuthException) rethrow; - throw SecureStorageException(details: e.toString()); - } - - if (kDebugMode) { - print('MiAuth 成功'); - } + // 5. 成功応答(保存は呼び出し側で TokenStore が担当) + if (kDebugMode) print('MiAuth 成功'); return MiAuthTokenResponse(token: check.token!, user: check.user); } on MisskeyAuthException { rethrow; @@ -164,21 +164,5 @@ class MisskeyMiAuthClient { } } - /// 保存された MiAuth トークンを取得 - Future getStoredAccessToken() async { - try { - return _storage.read(key: _accessTokenKey); - } on PlatformException catch (e) { - throw SecureStorageException(details: e.message, originalException: e); - } - } - - /// MiAuth の保存情報を削除 - Future clearTokens() async { - try { - await _storage.deleteAll(); - } on PlatformException catch (e) { - throw SecureStorageException(details: e.message, originalException: e); - } - } + // ストレージ操作は廃止 } diff --git a/lib/src/api/misskey_oauth_client.dart b/lib/src/api/misskey_oauth_client.dart index 3880c4a..8a93755 100644 --- a/lib/src/api/misskey_oauth_client.dart +++ b/lib/src/api/misskey_oauth_client.dart @@ -3,7 +3,6 @@ import 'dart:math'; import 'package:crypto/crypto.dart'; import 'package:dio/dio.dart'; import 'package:flutter/foundation.dart'; -import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:flutter/services.dart'; import 'package:flutter_web_auth_2/flutter_web_auth_2.dart'; import '../models/oauth_models.dart'; @@ -12,19 +11,32 @@ import '../exceptions/misskey_auth_exception.dart'; /// MisskeyのOAuth認証を管理するクライアント class MisskeyOAuthClient { final Dio _dio; - final FlutterSecureStorage _storage; - - // ストレージキー - static const _accessTokenKey = 'misskey_access_token'; - static const _refreshTokenKey = 'misskey_refresh_token'; - static const _expiresAtKey = 'misskey_expires_at'; - static const _hostKey = 'misskey_host'; + // 保存責務は削除(TokenStore が担当) + /// 認証通信で使用するHTTPクライアント + /// + /// [dio] を渡さない場合は、次のデフォルトタイムアウトで初期化: + /// - 接続: 10秒 + /// - 送信: 20秒 + /// - 受信: 20秒 + /// これらは [connectTimeout]/[sendTimeout]/[receiveTimeout] で上書き可能 MisskeyOAuthClient({ Dio? dio, - FlutterSecureStorage? storage, - }) : _dio = dio ?? Dio(), - _storage = storage ?? const FlutterSecureStorage(); + Duration? connectTimeout, + Duration? sendTimeout, + Duration? receiveTimeout, + }) : _dio = dio ?? + Dio(BaseOptions( + connectTimeout: connectTimeout ?? const Duration(seconds: 10), + sendTimeout: sendTimeout ?? const Duration(seconds: 20), + receiveTimeout: receiveTimeout ?? const Duration(seconds: 20), + )) { + if (dio != null) { + if (connectTimeout != null) _dio.options.connectTimeout = connectTimeout; + if (sendTimeout != null) _dio.options.sendTimeout = sendTimeout; + if (receiveTimeout != null) _dio.options.receiveTimeout = receiveTimeout; + } + } /// OAuth認証サーバー情報を取得 Future getOAuthServerInfo(String host) async { @@ -207,24 +219,8 @@ class MisskeyOAuthClient { codeVerifier: codeVerifier, ); - // 8. トークンを保存 - try { - await _saveTokens( - host: config.host, - accessToken: tokenResponse.accessToken, - refreshToken: tokenResponse.refreshToken, - expiresIn: tokenResponse.expiresIn, - ); - } on PlatformException catch (e) { - throw SecureStorageException(details: e.message, originalException: e); - } catch (e) { - if (e is MisskeyAuthException) rethrow; - throw SecureStorageException(details: e.toString()); - } - - if (kDebugMode) { - print('認証成功!'); - } + // 8. 成功(保存は呼び出し側で TokenStore が担当) + if (kDebugMode) print('認証成功!'); return tokenResponse; } on MisskeyAuthException { rethrow; @@ -315,42 +311,5 @@ class MisskeyOAuthClient { } } - /// トークンを保存 - Future _saveTokens({ - required String host, - required String accessToken, - String? refreshToken, - int? expiresIn, - }) async { - await _storage.write(key: _hostKey, value: host); - await _storage.write(key: _accessTokenKey, value: accessToken); - - if (refreshToken != null) { - await _storage.write(key: _refreshTokenKey, value: refreshToken); - } - - if (expiresIn != null) { - final expiresAt = DateTime.now().add(Duration(seconds: expiresIn)); - await _storage.write( - key: _expiresAtKey, value: expiresAt.toIso8601String()); - } - } - - /// 保存されたアクセストークンを取得 - Future getStoredAccessToken() async { - try { - return await _storage.read(key: _accessTokenKey); - } on PlatformException catch (e) { - throw SecureStorageException(details: e.message, originalException: e); - } - } - - /// トークンをクリア - Future clearTokens() async { - try { - await _storage.deleteAll(); - } on PlatformException catch (e) { - throw SecureStorageException(details: e.message, originalException: e); - } - } + // 保存・読み出し・クリアの責務は廃止 } diff --git a/lib/src/auth/misskey_auth_manager.dart b/lib/src/auth/misskey_auth_manager.dart new file mode 100644 index 0000000..d69d863 --- /dev/null +++ b/lib/src/auth/misskey_auth_manager.dart @@ -0,0 +1,155 @@ +import 'package:dio/dio.dart'; + +import '../api/misskey_miauth_client.dart'; +import '../api/misskey_oauth_client.dart'; +import '../exceptions/misskey_auth_exception.dart'; +import '../models/miauth_models.dart'; +import '../models/oauth_models.dart'; +import '../store/account_key.dart'; +import '../store/secure_token_store.dart'; +import '../store/stored_token.dart'; +import '../store/token_store.dart'; + +/// Misskey 認証の高レベル管理クラス +/// +/// - 認証(MiAuth/OAuth)の実行と、`TokenStore` への保存を仲介 +/// - OAuth 認証後は `/api/i` を呼び出し、`accountId` を自動解決 +/// - アクティブアカウントの設定/取得、トークン取得、サインアウト等を提供 +class MisskeyAuthManager { + final MisskeyMiAuthClient miauth; + final MisskeyOAuthClient oauth; + final TokenStore store; + final Dio dio; + + MisskeyAuthManager({ + required this.miauth, + required this.oauth, + required this.store, + Dio? dio, + Duration? connectTimeout, + Duration? sendTimeout, + Duration? receiveTimeout, + }) : dio = dio ?? + Dio(BaseOptions( + connectTimeout: connectTimeout ?? const Duration(seconds: 10), + sendTimeout: sendTimeout ?? const Duration(seconds: 20), + receiveTimeout: receiveTimeout ?? const Duration(seconds: 20), + )); + + /// 依存を既定実装で組み立てたインスタンスを返す + factory MisskeyAuthManager.defaultInstance() => MisskeyAuthManager( + miauth: MisskeyMiAuthClient(), + oauth: MisskeyOAuthClient(), + store: const SecureTokenStore(), + ); + + /// MiAuth で認証を実行し、トークンを保存 + /// + /// 認証後、ユーザー情報に含まれる `user.id` を `accountId` に採用 + Future loginWithMiAuth( + MisskeyMiAuthConfig config, { + bool setActive = true, + }) async { + final res = await miauth.authenticate(config); + // MiAuth は user 情報がレスポンスに含まれる + final user = res.user ?? {}; + final accountId = _resolveAccountIdFromUser(user); + final key = AccountKey(host: config.host, accountId: accountId); + final token = StoredToken( + accessToken: res.token, + tokenType: 'MiAuth', + user: user, + createdAt: DateTime.now(), + ); + await store.upsert(key, token); + if (setActive) { + await store.setActive(key); + } + return key; + } + + /// OAuth で認証を実行し、トークンを保存 + /// + /// 認証後に `/api/i` を呼び出して `accountId` を解決 + Future loginWithOAuth( + MisskeyOAuthConfig config, { + bool setActive = true, + }) async { + final tokenRes = await oauth.authenticate(config); + if (tokenRes == null) { + throw const MisskeyAuthException('OAuth認証が完了しませんでした'); + } + // 認可直後に /api/i で accountId を解決 + final user = await _fetchCurrentUser(config.host, tokenRes.accessToken); + final accountId = _resolveAccountIdFromUser(user); + final key = AccountKey(host: config.host, accountId: accountId); + final token = StoredToken( + accessToken: tokenRes.accessToken, + tokenType: 'OAuth', + scope: tokenRes.scope, + user: user, + createdAt: DateTime.now(), + ); + await store.upsert(key, token); + if (setActive) { + await store.setActive(key); + } + return key; + } + + /// `/api/i` を呼び出し、現在のユーザー情報を取得 + Future> _fetchCurrentUser( + String host, String accessToken) async { + try { + final url = 'https://$host/api/i'; + final response = await dio.post( + url, + // Misskeyの一般的な仕様に従い、リクエストボディに `i` でトークンを渡す + data: {'i': accessToken}, + ); + if (response.statusCode == 200 && response.data is Map) { + return response.data as Map; + } + throw ResponseParseException(details: 'Unexpected /api/i response'); + } on DioException catch (e) { + throw NetworkException(details: e.message, originalException: e); + } on FormatException catch (e) { + throw ResponseParseException(details: e.message, originalException: e); + } + } + + /// ユーザー情報から `accountId` を解決 + String _resolveAccountIdFromUser(Map user) { + final id = user['id']; + if (id is String && id.isNotEmpty) return id; + throw ResponseParseException(details: 'User id not found'); + } + + /// アクティブアカウントの `StoredToken` を取得。未設定時は `null` + Future currentToken() async { + final active = await store.getActive(); + if (active == null) return null; + return store.read(active); + } + + /// 指定アカウントの `StoredToken` を取得。未保存時は `null` + Future tokenOf(AccountKey key) => store.read(key); + + /// アクティブアカウントを設定する + Future setActive(AccountKey key) => store.setActive(key); + + /// 現在のアクティブアカウントを取得 + Future getActive() => store.getActive(); + + /// アクティブアカウント設定を解除 + Future clearActive() => store.setActive(null); + + /// 保存済みアカウントの一覧を取得 + Future> listAccounts() => store.list(); + + /// 指定アカウントのトークンを削除(サインアウト相当) + Future signOut(AccountKey key) => store.delete(key); + + /// すべてのトークンを削除(全サインアウト) + Future signOutAll() => store.clearAll(); +} diff --git a/lib/src/models/miauth_models.dart b/lib/src/models/miauth_models.dart index 626ef43..65901a5 100644 --- a/lib/src/models/miauth_models.dart +++ b/lib/src/models/miauth_models.dart @@ -9,7 +9,7 @@ class MisskeyMiAuthConfig { /// カスタム URL スキーム(例: misskeyauth) final String callbackScheme; - /// 要求する権限一覧(例: ['read:account', 'write:notes']) + /// 要求する権限一覧(例: `read:account`, `write:notes`) final List permissions; /// アプリアイコンの URL(任意) diff --git a/lib/src/models/misskey_server_info.dart b/lib/src/models/misskey_server_info.dart new file mode 100644 index 0000000..980ef75 --- /dev/null +++ b/lib/src/models/misskey_server_info.dart @@ -0,0 +1,69 @@ +/// Misskeyサーバーの情報を表すクラス +class MisskeyServerInfo { + /// サーバーのホスト + final String host; + + /// OAuth 2.0がサポートされているかどうか + final bool supportsOAuth; + + /// OAuth認証エンドポイント + final String? authorizationEndpoint; + + /// OAuthトークンエンドポイント + final String? tokenEndpoint; + + /// サーバーのバージョン情報 + final String? version; + + /// サーバーの名前 + final String? name; + + /// サーバーの説明 + final String? description; + + const MisskeyServerInfo({ + required this.host, + required this.supportsOAuth, + this.authorizationEndpoint, + this.tokenEndpoint, + this.version, + this.name, + this.description, + }); + + /// OAuth認証が利用可能かどうかを判定 + bool get canUseOAuth { + return supportsOAuth && + authorizationEndpoint != null && + tokenEndpoint != null; + } + + /// サーバー情報をJSONから作成するファクトリメソッド + factory MisskeyServerInfo.fromJson(String host, Map json) { + final hasOAuthEndpoints = json.containsKey('authorization_endpoint') && + json.containsKey('token_endpoint'); + + return MisskeyServerInfo( + host: host, + supportsOAuth: hasOAuthEndpoints, + authorizationEndpoint: json['authorization_endpoint'] as String?, + tokenEndpoint: json['token_endpoint'] as String?, + version: json['version'] as String?, + name: json['name'] as String?, + description: json['description'] as String?, + ); + } + + /// デフォルトのサーバー情報を作成するファクトリメソッド + factory MisskeyServerInfo.defaultInfo(String host) { + return MisskeyServerInfo( + host: host, + supportsOAuth: false, + ); + } + + @override + String toString() { + return 'MisskeyServerInfo(host: $host, oauth: $supportsOAuth)'; + } +} diff --git a/lib/src/models/oauth_models.dart b/lib/src/models/oauth_models.dart index 621cda2..0bb6aff 100644 --- a/lib/src/models/oauth_models.dart +++ b/lib/src/models/oauth_models.dart @@ -60,9 +60,6 @@ class OAuthTokenResponse { /// トークンタイプ(通常は"Bearer") final String tokenType; - /// リフレッシュトークン(オプション) - final String? refreshToken; - /// トークンの有効期限(秒) final int? expiresIn; @@ -75,7 +72,6 @@ class OAuthTokenResponse { const OAuthTokenResponse({ required this.accessToken, required this.tokenType, - this.refreshToken, this.expiresIn, this.scope, this.idToken, @@ -85,7 +81,6 @@ class OAuthTokenResponse { return OAuthTokenResponse( accessToken: json['access_token'] as String, tokenType: json['token_type'] as String, - refreshToken: json['refresh_token'] as String?, expiresIn: json['expires_in'] as int?, scope: json['scope'] as String?, idToken: json['id_token'] as String?, diff --git a/lib/src/store/account_key.dart b/lib/src/store/account_key.dart new file mode 100644 index 0000000..f8bb18c --- /dev/null +++ b/lib/src/store/account_key.dart @@ -0,0 +1,45 @@ +/// Misskey のアカウントを一意に識別するキー +/// +/// - 複数アカウントのトークンを扱う際の主キーとして使用 +/// - `host` と `accountId` の組み合わせで一意に +class AccountKey { + /// Misskey サーバーのホスト名(例: `misskey.io`) + final String host; + + /// Misskey 側で付与されるユーザーID(例: `9arsrr3d8x`) + final String accountId; + + const AccountKey({required this.host, required this.accountId}); + + /// ストレージ用の内部キーを生成する + /// + /// `SecureTokenStore` の実装で使用する。アプリ側で直接利用する必要はないが、 + /// デバッグ時の識別子として役立つ場合がある + String storageKey() => 'misskey_token::$host::$accountId'; + + /// JSON へ変換 + Map toJson() => { + 'host': host, + 'accountId': accountId, + }; + + /// JSON から復元 + factory AccountKey.fromJson(Map json) => AccountKey( + host: json['host'] as String, + accountId: json['accountId'] as String, + ); + + @override + bool operator ==(Object other) => + identical(this, other) || + other is AccountKey && + runtimeType == other.runtimeType && + host == other.host && + accountId == other.accountId; + + @override + int get hashCode => Object.hash(host, accountId); + + @override + String toString() => 'AccountKey(host: $host, accountId: $accountId)'; +} diff --git a/lib/src/store/secure_token_store.dart b/lib/src/store/secure_token_store.dart new file mode 100644 index 0000000..28f2239 --- /dev/null +++ b/lib/src/store/secure_token_store.dart @@ -0,0 +1,142 @@ +import 'dart:convert'; + +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; + +import 'account_key.dart'; +import 'stored_token.dart'; +import 'token_store.dart'; + +/// `FlutterSecureStorage` を用いた `TokenStore` の実装 +/// +/// - iOS/Android のキーチェーン/Keystore に保存(平文ファイルは使用しない) +/// - 内部インデックス(`_indexKey`)でアカウント一覧を管理 +/// - アクティブアカウントは `_activeKey` に JSON として永続化 +class SecureTokenStore implements TokenStore { + final FlutterSecureStorage storage; + + static const String _indexKey = 'misskey_accounts_index'; + static const String _activeKey = 'misskey_active_account'; + + const SecureTokenStore({FlutterSecureStorage? storage}) + : storage = storage ?? const FlutterSecureStorage(); + + @override + + /// トークンを保存または更新 + Future upsert(AccountKey key, StoredToken token) async { + final Map value = token.toJson(); + await storage.write(key: key.storageKey(), value: jsonEncode(value)); + await _addToIndex(key); + } + + @override + + /// トークンを取得する。存在しない場合は `null` を返す + Future read(AccountKey key) async { + final raw = await storage.read(key: key.storageKey()); + if (raw == null) return null; + final map = jsonDecode(raw) as Map; + return StoredToken.fromJson(map); + } + + @override + + /// 保存済みアカウントの一覧を返す + Future> list() async { + final keys = await _readIndex(); + final List entries = []; + for (final k in keys) { + final token = await read(k); + String? userName; + DateTime? createdAt; + if (token != null) { + final user = token.user; + if (user != null) { + userName = + (user['name'] ?? user['username'] ?? user['userName']) as String?; + } + createdAt = token.createdAt; + } + entries.add(AccountEntry(k, userName: userName, createdAt: createdAt)); + } + return entries; + } + + @override + + /// 指定アカウントのトークンを削除する。アクティブ一致時は解除する + Future delete(AccountKey key) async { + await storage.delete(key: key.storageKey()); + await _removeFromIndex(key); + final active = await getActive(); + if (active != null && active == key) { + await setActive(null); + } + } + + @override + + /// すべてのトークンと関連メタ情報(インデックス/アクティブ)を削除 + Future clearAll() async { + final keys = await _readIndex(); + for (final k in keys) { + await storage.delete(key: k.storageKey()); + } + await storage.delete(key: _indexKey); + await storage.delete(key: _activeKey); + } + + @override + + /// アクティブアカウントを設定する。`null` で解除 + Future setActive(AccountKey? key) async { + if (key == null) { + await storage.delete(key: _activeKey); + return; + } + final json = jsonEncode(key.toJson()); + await storage.write(key: _activeKey, value: json); + } + + @override + + /// 現在のアクティブアカウントを取得。未設定時は `null` + Future getActive() async { + final raw = await storage.read(key: _activeKey); + if (raw == null) return null; + final map = jsonDecode(raw) as Map; + return AccountKey.fromJson(map); + } + + /// インデックスにアカウントを追加(重複は無視) + Future _addToIndex(AccountKey key) async { + final keys = await _readIndex(); + if (!keys.contains(key)) { + keys.add(key); + await _writeIndex(keys); + } + } + + /// インデックスからアカウントを削除 + Future _removeFromIndex(AccountKey key) async { + final keys = await _readIndex(); + keys.removeWhere((k) => k == key); + await _writeIndex(keys); + } + + /// インデックスを読み出す。未作成時は空配列を返す + Future> _readIndex() async { + final raw = await storage.read(key: _indexKey); + if (raw == null) return []; + final list = jsonDecode(raw) as List; + return list + .map((e) => AccountKey.fromJson(e as Map)) + .toList(); + } + + /// インデックスを書き込み + Future _writeIndex(List keys) async { + final json = jsonEncode(keys.map((k) => k.toJson()).toList()); + await storage.write(key: _indexKey, value: json); + } +} diff --git a/lib/src/store/stored_token.dart b/lib/src/store/stored_token.dart new file mode 100644 index 0000000..9456c1b --- /dev/null +++ b/lib/src/store/stored_token.dart @@ -0,0 +1,61 @@ +import 'account_key.dart'; + +/// 保存済みのトークン情報を表すモデル +/// +/// - `tokenType` は `MiAuth` または `OAuth` +class StoredToken { + /// アクセストークン本体(Bearer) + final String accessToken; + + /// トークンの種類(`MiAuth` / `OAuth`) + final String tokenType; // 'MiAuth' | 'OAuth' + /// 要求スコープ(OAuth のみ) + final String? scope; + + /// `/api/i` 等で取得したユーザー情報(任意) + final Map? user; + + /// ライブラリが保存した日時(デバッグ/表示用途) + final DateTime? createdAt; + + const StoredToken({ + required this.accessToken, + required this.tokenType, + this.scope, + this.user, + this.createdAt, + }); + + /// JSON へ変換 + Map toJson() => { + 'accessToken': accessToken, + 'tokenType': tokenType, + if (scope != null) 'scope': scope, + if (user != null) 'user': user, + if (createdAt != null) 'createdAt': createdAt!.toIso8601String(), + }; + + /// JSON から復元 + factory StoredToken.fromJson(Map json) => StoredToken( + accessToken: json['accessToken'] as String, + tokenType: json['tokenType'] as String, + scope: json['scope'] as String?, + user: json['user'] as Map?, + createdAt: json['createdAt'] != null + ? DateTime.tryParse(json['createdAt'] as String) + : null, + ); +} + +/// アカウント一覧に表示するメタ情報 +/// +/// - `key` が対象アカウントを表す +/// - `userName` は取得できた場合にのみ表示用に保持 +/// - `createdAt` は保存日時の目安 +class AccountEntry { + final AccountKey key; + final String? userName; + final DateTime? createdAt; + + const AccountEntry(this.key, {this.userName, this.createdAt}); +} diff --git a/lib/src/store/token_store.dart b/lib/src/store/token_store.dart new file mode 100644 index 0000000..c8d9878 --- /dev/null +++ b/lib/src/store/token_store.dart @@ -0,0 +1,29 @@ +import 'account_key.dart'; +import 'stored_token.dart'; + +/// トークン保存の抽象インターフェース +/// +/// - 複数アカウントの並行管理を前提とし、`AccountKey` を主キーとして扱う +/// - 実装は `SecureTokenStore` を既定とし、アプリ側で差し替え可能 +abstract class TokenStore { + /// トークンを保存または更新(存在すれば上書き) + Future upsert(AccountKey key, StoredToken token); + + /// 指定アカウントのトークンを取得(未保存なら `null`) + Future read(AccountKey key); + + /// 保存済みアカウントの一覧を取得 + Future> list(); + + /// 指定アカウントのトークンを削除 + Future delete(AccountKey key); + + /// すべてのアカウントのトークンを削除 + Future clearAll(); + + /// アクティブ(デフォルト)アカウントを設定 + Future setActive(AccountKey? key); + + /// 現在のアクティブ(デフォルト)アカウントを取得 + Future getActive(); +} diff --git a/test/widget_test.dart b/test/widget_test.dart index 167d780..79dc47e 100644 --- a/test/widget_test.dart +++ b/test/widget_test.dart @@ -4,27 +4,3 @@ // utility in the flutter_test package. For example, you can send tap and scroll // gestures. You can also use WidgetTester to find child widgets in the widget // tree, read text, and verify that the values of widget properties are correct. - -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; - -import 'package:misskey_auth/main.dart'; - -void main() { - testWidgets('Counter increments smoke test', (WidgetTester tester) async { - // Build our app and trigger a frame. - await tester.pumpWidget(const MyApp()); - - // Verify that our counter starts at 0. - expect(find.text('0'), findsOneWidget); - expect(find.text('1'), findsNothing); - - // Tap the '+' icon and trigger a frame. - await tester.tap(find.byIcon(Icons.add)); - await tester.pump(); - - // Verify that our counter has incremented. - expect(find.text('0'), findsNothing); - expect(find.text('1'), findsOneWidget); - }); -} From 764a14a55d54c409a144ad036cafea94e7f8c4ee Mon Sep 17 00:00:00 2001 From: librarian <57712678+LibraryLibrarian@users.noreply.github.com> Date: Sat, 16 Aug 2025 03:28:02 +0900 Subject: [PATCH 2/4] =?UTF-8?q?chore:=20flutter=E8=B5=B7=E5=8B=95=E6=A7=8B?= =?UTF-8?q?=E6=88=90=E3=81=AE=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .vscode/launch.json | 45 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) create mode 100644 .vscode/launch.json diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..787d734 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,45 @@ +{ + // IntelliSense を使用して利用可能な属性を学べます。 + // 既存の属性の説明をホバーして表示します。 + // 詳細情報は次を確認してください: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + { + "name": "misskey_auth", + "request": "launch", + "type": "dart" + }, + { + "name": "misskey_auth (profile mode)", + "request": "launch", + "type": "dart", + "flutterMode": "profile" + }, + { + "name": "misskey_auth (release mode)", + "request": "launch", + "type": "dart", + "flutterMode": "release" + }, + { + "name": "example", + "cwd": "example", + "request": "launch", + "type": "dart" + }, + { + "name": "example (profile mode)", + "cwd": "example", + "request": "launch", + "type": "dart", + "flutterMode": "profile" + }, + { + "name": "example (release mode)", + "cwd": "example", + "request": "launch", + "type": "dart", + "flutterMode": "release" + } + ] +} \ No newline at end of file From 60a9502bd15f74ac5d423627d21755d6d46e372d Mon Sep 17 00:00:00 2001 From: librarian <57712678+LibraryLibrarian@users.noreply.github.com> Date: Sat, 16 Aug 2025 04:00:38 +0900 Subject: [PATCH 3/4] =?UTF-8?q?feat:=20=E3=83=90=E3=83=BC=E3=82=B8?= =?UTF-8?q?=E3=83=A7=E3=83=B30.1.3-beta=20=E3=83=AA=E3=83=AA=E3=83=BC?= =?UTF-8?q?=E3=82=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - マルチアカウントのトークン管理を追加し、`TokenStore`を介してトークンの保存・取得を実装 - `MisskeyAuthManager`を導入し、OAuthおよびMiAuthの認証フローを統合管理 - 不要なストレージ関連APIを削除し、クライアントの責任を明確化 - READMEとCHANGELOGを更新し、新機能と変更点を反映 --- CHANGELOG.md | 26 +++ README.md | 472 ++++++++++++++++++++++++++++++++++----------------- pubspec.yaml | 2 +- 3 files changed, 346 insertions(+), 154 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 66be871..4ad28ce 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,32 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.1.3-beta] - 2025-08-15 + +### Added +- Multi-account token management via `TokenStore` abstraction +- Default secure implementation: `SecureTokenStore` (backed by `flutter_secure_storage`) +- High-level API `MisskeyAuthManager` to orchestrate OAuth/MiAuth authentication and token persistence +- Models and types for account/token management: `StoredToken`, `AccountKey`, `AccountEntry` +- Public exports for store/manager types from `misskey_auth.dart` + +### Changed +- `MisskeyOAuthClient` and `MisskeyMiAuthClient` no longer persist tokens; they only perform the authentication flow and return results +- Constructors now focus on networking concerns (`Dio` and timeouts) + +### Removed +- Storage-related APIs from clients: + - `MisskeyOAuthClient.getStoredAccessToken()` + - `MisskeyOAuthClient.clearTokens()` + - `MisskeyMiAuthClient.getStoredAccessToken()` + - `MisskeyMiAuthClient.clearTokens()` +- `MisskeyMiAuthClient` constructor parameter for storage injection + +### Breaking Changes +- Removed storage APIs from both `MisskeyOAuthClient` and `MisskeyMiAuthClient` (use `MisskeyAuthManager` and `TokenStore` instead) +- `MisskeyMiAuthClient` constructor signature changed (storage parameter removed) +- Token lifecycle (save/read/delete) responsibilities moved from clients to `TokenStore`/`MisskeyAuthManager` + ## [0.1.2-beta] - 2025-08-15 ### Added diff --git a/README.md b/README.md index f4943cf..4a5454b 100644 --- a/README.md +++ b/README.md @@ -12,17 +12,19 @@ ## English -A Flutter library for Misskey OAuth authentication with automatic fallback to MiAuth for older servers. +A Flutter library for Misskey OAuth authentication with MiAuth support and multi-account token management. ### Features - OAuth 2.0 authentication for Misskey servers (v2023.9.0+) -- Automatic fallback to MiAuth for older servers (Planned response in the future.) +- MiAuth authentication for older servers - External browser authentication (no embedded WebViews) -- Secure token storage using flutter_secure_storage +- Secure token storage using `flutter_secure_storage` - Cross-platform support (iOS/Android) - PKCE (Proof Key for Code Exchange) implementation - Custom URL scheme handling for authentication callbacks +- Multi-account token storage and account switching +- High-level `MisskeyAuthManager` to run flows and persist tokens ### Installation @@ -30,7 +32,7 @@ Add this to your package's `pubspec.yaml` file: ```yaml dependencies: - misskey_auth: ^0.1.2-beta + misskey_auth: ^0.1.3-beta ``` ### Quick Start @@ -80,23 +82,47 @@ Misskey's OAuth 2.0 follows the IndieAuth specification. You need: ``` -#### 2. Basic Authentication +#### 2. Basic Authentication (Recommended: via MisskeyAuthManager) ```dart import 'package:misskey_auth/misskey_auth.dart'; -// Authentication configuration -final config = MisskeyOAuthConfig( - host: 'misskey.io', - clientId: 'https://yourpage/yourapp/', - redirectUri: 'https://yourpage/yourapp/redirect.html', - scope: 'read:account write:notes', - callbackScheme: 'yourscheme', +// Create manager with default dependencies +final auth = MisskeyAuthManager.defaultInstance(); + +// OAuth +final oauthKey = await auth.loginWithOAuth( + MisskeyOAuthConfig( + host: 'misskey.io', + clientId: 'https://yourpage/yourapp/', + redirectUri: 'https://yourpage/yourapp/redirect.html', + scope: 'read:account write:notes', + callbackScheme: 'yourscheme', + ), + setActive: true, ); -// Create client and authenticate -final client = MisskeyOAuthClient(); -final token = await client.authenticate(config); +// MiAuth +final miKey = await auth.loginWithMiAuth( + MisskeyMiAuthConfig( + host: 'misskey.io', + appName: 'Your App', + callbackScheme: 'yourscheme', + permissions: ['read:account', 'write:notes'], + iconUrl: 'https://example.com/icon.png', + ), + setActive: true, +); + +// Tokens +final current = await auth.currentToken(); +final specific = await auth.tokenOf(oauthKey); + +// Accounts +final accounts = await auth.listAccounts(); +await auth.setActive(miKey); +await auth.signOut(oauthKey); +await auth.signOutAll(); ``` #### 3. Platform Configuration @@ -141,12 +167,12 @@ Add to `android/app/src/main/AndroidManifest.xml`: - OAuth: Since it needs to return to an HTTPS `redirect_uri` from the authorization server, `redirect.html` placed there ultimately redirects back to `yourscheme://...` for the app. - MiAuth: The `callback` query of the authentication start URL specifies `yourscheme://...` from the beginning (no need for `https`). -##### Example of MiAuth +##### Example of MiAuth (no persistence) ```dart import 'package:misskey_auth/misskey_auth.dart'; -final miClient = MisskeyMiAuthClient(); +final miClient = MisskeyMiAuthClient(); // does not save tokens final miConfig = MisskeyMiAuthConfig( host: 'misskey.io', appName: 'Your App', @@ -154,15 +180,35 @@ final miConfig = MisskeyMiAuthConfig( permissions: ['read:account', 'write:notes'], iconUrl: 'https://example.com/icon.png', // Optional ); -final miRes = await miClient.authenticate(miConfig); +final miRes = await miClient.authenticate(miConfig); // returns token only +``` + +##### Example of MiAuth (with persistence via MisskeyAuthManager) + +```dart +import 'package:misskey_auth/misskey_auth.dart'; + +final auth = MisskeyAuthManager.defaultInstance(); +final key = await auth.loginWithMiAuth( + MisskeyMiAuthConfig( + host: 'misskey.io', + appName: 'Your App', + callbackScheme: 'yourscheme', + permissions: ['read:account', 'write:notes'], + iconUrl: 'https://example.com/icon.png', + ), + setActive: true, // also mark as active account +); +// Token is saved via SecureTokenStore; you can read it later: +final current = await auth.currentToken(); ``` -##### Example of OAuth +##### Example of OAuth (no persistence) ```dart import 'package:misskey_auth/misskey_auth.dart'; -final oauthClient = MisskeyOAuthClient(); +final oauthClient = MisskeyOAuthClient(); // does not save tokens final oauthConfig = MisskeyOAuthConfig( host: 'misskey.io', clientId: 'https://yourpage/yourapp/', @@ -170,7 +216,27 @@ final oauthConfig = MisskeyOAuthConfig( scope: 'read:account write:notes', callbackScheme: 'yourscheme', // Scheme registered on the app side ); -final token = await oauthClient.authenticate(oauthConfig); +final token = await oauthClient.authenticate(oauthConfig); // returns token only +``` + +##### Example of OAuth (with persistence via MisskeyAuthManager) + +```dart +import 'package:misskey_auth/misskey_auth.dart'; + +final auth = MisskeyAuthManager.defaultInstance(); +final key = await auth.loginWithOAuth( + MisskeyOAuthConfig( + host: 'misskey.io', + clientId: 'https://yourpage/yourapp/', + redirectUri: 'https://yourpage/yourapp/redirect.html', + scope: 'read:account write:notes', + callbackScheme: 'yourscheme', + ), + setActive: true, +); +// Token is saved via SecureTokenStore; you can read it later: +final current = await auth.currentToken(); ``` ##### How to Support Both Methods in the Same App @@ -213,17 +279,11 @@ Main client for handling Misskey OAuth authentication. ```dart class MisskeyOAuthClient { - /// Authenticate with Misskey server + /// Authenticate with Misskey server (no persistence) Future authenticate(MisskeyOAuthConfig config); /// Get OAuth server information Future getOAuthServerInfo(String host); - - /// Get stored access token - Future getStoredAccessToken(); - - /// Clear stored tokens - Future clearTokens(); } ``` @@ -233,17 +293,73 @@ Main client for handling Misskey MiAuth authentication. ```dart class MisskeyMiAuthClient { - /// Authenticate with Misskey server using MiAuth + /// Authenticate with Misskey server using MiAuth (no persistence) Future authenticate(MisskeyMiAuthConfig config); - - /// Get stored access token - Future getStoredAccessToken(); - - /// Clear stored tokens - Future clearTokens(); +} + +#### MisskeyAuthManager + +High-level API to run OAuth/MiAuth and persist tokens via `TokenStore`. +The default `defaultInstance()` uses `SecureTokenStore`. + +```dart +class MisskeyAuthManager { + static MisskeyAuthManager defaultInstance(); + + Future loginWithOAuth(MisskeyOAuthConfig config, { bool setActive = true }); + Future loginWithMiAuth(MisskeyMiAuthConfig config, { bool setActive = true }); + + Future currentToken(); + Future tokenOf(AccountKey key); + + Future setActive(AccountKey key); + Future getActive(); + Future clearActive(); + + Future> listAccounts(); + Future signOut(AccountKey key); + Future signOutAll(); +} +``` + +#### TokenStore / SecureTokenStore + +```dart +abstract class TokenStore { + Future upsert(AccountKey key, StoredToken token); + Future read(AccountKey key); + Future> list(); + Future delete(AccountKey key); + Future clearAll(); + Future setActive(AccountKey? key); + Future getActive(); } ``` +#### Models (excerpt) + +```dart +class StoredToken { + final String accessToken; + final String tokenType; // 'MiAuth' | 'OAuth' + final String? scope; + final Map? user; + final DateTime? createdAt; +} + +class AccountKey { + final String host; + final String accountId; +} + +class AccountEntry { + final AccountKey key; + final String? userName; + final DateTime? createdAt; +} +``` +``` + ### Error Handling The library provides comprehensive error handling with custom exception classes for different scenarios. For detailed information about each exception class and their usage, please refer to the documentation on pub.dev. @@ -265,47 +381,6 @@ The library includes exception classes for: This project is licensed under the 3-Clause BSD License - see the [LICENSE](LICENSE) file for details. -### Example App Verification - -This library includes a sample app to verify its functionality. - -#### Running the Example App - -1. Clone or download the repository -2. Navigate to the example directory: - ```bash - cd example - ``` -3. Install dependencies: - ```bash - flutter pub get - ``` - -4. Run the app: - ```bash - flutter run - ``` - -#### Features in the Example App - -- **Server Info Check**: Verify if Misskey server supports OAuth 2.0 -- **Authentication Setup**: Configure host, client ID, redirect URI, scope, and callback scheme -- **OAuth Flow**: Execute authentication using actual browser -- **Token Management**: Display and delete access tokens after successful authentication -- **Error Handling**: Verify behavior in various error scenarios - -#### Default Configuration - -The example app comes with the following default values: - -- **Host**: `misskey.io` -- **Client ID**: `https://librarylibrarian.github.io/misskey_auth/` -- **Redirect URI**: `https://librarylibrarian.github.io/misskey_auth/redirect.html` -- **Scope**: `read:account write:notes` -- **Callback Scheme**: `misskeyauth` - -These values are provided for testing purposes, but you should change them to your own values when developing actual apps. - ### Related Links - [Misskey OAuth Documentation](https://misskey-hub.net/en/docs/for-developers/api/token/oauth/) @@ -316,17 +391,19 @@ These values are provided for testing purposes, but you should change them to yo ## Japanese -MisskeyのOAuth認証・MiAuth認証をFlutterアプリで簡単に扱うためのライブラリ。 +MisskeyのOAuth認証・MiAuth認証に加え、マルチアカウントのトークン管理を提供するFlutterライブラリ。 ### 内容 - MisskeyサーバーのOAuth 2.0認証対応(v2023.9.0以降) -- 古いサーバーでは自動的にMiAuth認証にフォールバック(今後対応予定) +- 古いサーバー向けMiAuth認証 - 埋め込みWebViewを使用しない認証 -- flutter_secure_storageを使用したトークン保存 +- `flutter_secure_storage` を使用したセキュアなトークン保存 - クロスプラットフォーム対応(iOS/Android) - PKCE(Proof Key for Code Exchange)実装 - 認証コールバック用カスタムURLスキーム対応 +- マルチアカウントのトークン保存とアカウント切替 +- 認証と保存を仲介する高レベルAPI `MisskeyAuthManager` ### インストール @@ -334,11 +411,31 @@ MisskeyのOAuth認証・MiAuth認証をFlutterアプリで簡単に扱うため ```yaml dependencies: - misskey_auth: ^0.1.2-beta + misskey_auth: ^0.1.3-beta ``` ### クイックスタート +#### かんたん例(MisskeyAuthManager) + +```dart +import 'package:misskey_auth/misskey_auth.dart'; + +final auth = MisskeyAuthManager.defaultInstance(); + +// 認証後にトークンを自動保存 +final key = await auth.loginWithOAuth( + MisskeyOAuthConfig( + host: 'misskey.io', + clientId: 'https://yourpage/yourapp/', + redirectUri: 'https://yourpage/yourapp/redirect.html', + scope: 'read:account write:notes', + callbackScheme: 'yourscheme', + ), + setActive: true, +); +``` + #### 1. client_idページの設定 MisskeyのOAuth 2.0はIndieAuth仕様に準拠しています。以下が必要です: @@ -384,23 +481,46 @@ MisskeyのOAuth 2.0はIndieAuth仕様に準拠しています。以下が必要 ``` -#### 2. 基本的な認証 +#### 2. 基本的な認証(推奨: MisskeyAuthManager 経由) ```dart import 'package:misskey_auth/misskey_auth.dart'; -// 認証設定 -final config = MisskeyOAuthConfig( - host: 'misskey.io', - clientId: 'https://yourpage/yourapp/', - redirectUri: 'https://yourpage/yourapp/redirect.html', - scope: 'read:account write:notes', - callbackScheme: 'yourscheme', +final auth = MisskeyAuthManager.defaultInstance(); + +// OAuth +final oauthKey = await auth.loginWithOAuth( + MisskeyOAuthConfig( + host: 'misskey.io', + clientId: 'https://yourpage/yourapp/', + redirectUri: 'https://yourpage/yourapp/redirect.html', + scope: 'read:account write:notes', + callbackScheme: 'yourscheme', + ), + setActive: true, ); -// クライアント生成と認証 -final client = MisskeyOAuthClient(); -final token = await client.authenticate(config); +// MiAuth +final miKey = await auth.loginWithMiAuth( + MisskeyMiAuthConfig( + host: 'misskey.io', + appName: 'Your App', + callbackScheme: 'yourscheme', + permissions: ['read:account', 'write:notes'], + iconUrl: 'https://example.com/icon.png', + ), + setActive: true, +); + +// トークン取得 +final current = await auth.currentToken(); +final specific = await auth.tokenOf(oauthKey); + +// アカウント管理 +final accounts = await auth.listAccounts(); +await auth.setActive(miKey); +await auth.signOut(oauthKey); +await auth.signOutAll(); ``` #### 3. プラットフォーム設定 @@ -446,12 +566,12 @@ final token = await client.authenticate(config); - OAuth: 認可サーバーからはHTTPSの`redirect_uri`に戻る必要があるため、そこに配置した`redirect.html`が最終的に`yourscheme://...`へリダイレクトしてアプリに戻します。 - MiAuth: 認証開始URLの`callback`クエリに、最初から`yourscheme://...`を指定します(`https`は不要)。 -##### MiAuth の例(Dart) +##### MiAuth の例(保存無し) ```dart import 'package:misskey_auth/misskey_auth.dart'; -final miClient = MisskeyMiAuthClient(); +final miClient = MisskeyMiAuthClient(); // 保存はしません final miConfig = MisskeyMiAuthConfig( host: 'misskey.io', appName: 'Your App', @@ -459,15 +579,35 @@ final miConfig = MisskeyMiAuthConfig( permissions: ['read:account', 'write:notes'], iconUrl: 'https://example.com/icon.png', // 任意 ); -final miRes = await miClient.authenticate(miConfig); +final miRes = await miClient.authenticate(miConfig); // トークンのみ返します +``` + +##### MiAuth の例(MisskeyAuthManager による保存あり) + +```dart +import 'package:misskey_auth/misskey_auth.dart'; + +final auth = MisskeyAuthManager.defaultInstance(); +final key = await auth.loginWithMiAuth( + MisskeyMiAuthConfig( + host: 'misskey.io', + appName: 'Your App', + callbackScheme: 'yourscheme', + permissions: ['read:account', 'write:notes'], + iconUrl: 'https://example.com/icon.png', + ), + setActive: true, +); +// トークンは SecureTokenStore に保存され、後から取得できます +final current = await auth.currentToken(); ``` -##### OAuth の例 +##### OAuth の例(保存無し) ```dart import 'package:misskey_auth/misskey_auth.dart'; -final oauthClient = MisskeyOAuthClient(); +final oauthClient = MisskeyOAuthClient(); // 保存はしません final oauthConfig = MisskeyOAuthConfig( host: 'misskey.io', clientId: 'https://yourpage/yourapp/', @@ -475,7 +615,27 @@ final oauthConfig = MisskeyOAuthConfig( scope: 'read:account write:notes', callbackScheme: 'yourscheme', // アプリ側で登録したスキーム ); -final token = await oauthClient.authenticate(oauthConfig); +final token = await oauthClient.authenticate(oauthConfig); // トークンのみ返します +``` + +##### OAuth の例(MisskeyAuthManager による保存あり) + +```dart +import 'package:misskey_auth/misskey_auth.dart'; + +final auth = MisskeyAuthManager.defaultInstance(); +final key = await auth.loginWithOAuth( + MisskeyOAuthConfig( + host: 'misskey.io', + clientId: 'https://yourpage/yourapp/', + redirectUri: 'https://yourpage/yourapp/redirect.html', + scope: 'read:account write:notes', + callbackScheme: 'yourscheme', + ), + setActive: true, +); +// トークンは SecureTokenStore に保存され、後から取得できます +final current = await auth.currentToken(); ``` ##### 両方式を同一アプリでサポートするには @@ -519,17 +679,11 @@ Misskey OAuth認証を処理するメインクラス ```dart class MisskeyOAuthClient { - /// Misskeyサーバーで認証を実行 + /// Misskeyサーバーで認証を実行(保存は行いません) Future authenticate(MisskeyOAuthConfig config); /// OAuthサーバー情報を取得 Future getOAuthServerInfo(String host); - - /// 保存されたアクセストークンを取得 - Future getStoredAccessToken(); - - /// 保存されたトークンを削除 - Future clearTokens(); } ``` @@ -539,14 +693,68 @@ Misskey MiAuth認証を処理するメインクラス ```dart class MisskeyMiAuthClient { - /// MisskeyサーバーでMiAuth認証を実行 + /// MisskeyサーバーでMiAuth認証を実行(Tokenの保存はされません) Future authenticate(MisskeyMiAuthConfig config); - - /// 保存されたアクセストークンを取得 - Future getStoredAccessToken(); - - /// 保存されたトークンを削除 - Future clearTokens(); +} + +#### MisskeyAuthManager + +`TokenStore` を介して OAuth/MiAuth を実行し、トークンを保存する高レベルAPI。 + +```dart +class MisskeyAuthManager { + static MisskeyAuthManager defaultInstance(); + + Future loginWithOAuth(MisskeyOAuthConfig config, { bool setActive = true }); + Future loginWithMiAuth(MisskeyMiAuthConfig config, { bool setActive = true }); + + Future currentToken(); + Future tokenOf(AccountKey key); + + Future setActive(AccountKey key); + Future getActive(); + Future clearActive(); + + Future> listAccounts(); + Future signOut(AccountKey key); + Future signOutAll(); +} +``` + +#### TokenStore / SecureTokenStore + +```dart +abstract class TokenStore { + Future upsert(AccountKey key, StoredToken token); + Future read(AccountKey key); + Future> list(); + Future delete(AccountKey key); + Future clearAll(); + Future setActive(AccountKey? key); + Future getActive(); +} +``` + +#### モデル(抜粋) + +```dart +class StoredToken { + final String accessToken; + final String tokenType; // 'MiAuth' | 'OAuth' + final String? scope; + final Map? user; + final DateTime? createdAt; +} + +class AccountKey { + final String host; + final String accountId; +} + +class AccountEntry { + final AccountKey key; + final String? userName; + final DateTime? createdAt; } ``` @@ -571,48 +779,6 @@ class MisskeyMiAuthClient { このプロジェクトは3-Clause BSD Licenseの下で公開されています。詳細は[LICENSE](LICENSE)ファイルをご覧ください。 -### サンプルアプリでの確認方法 - -このライブラリには動作を確認できるサンプルアプリが同梱されています。 - -#### サンプルアプリの実行 - -1. リポジトリをクローンまたはダウンロード -2. サンプルアプリディレクトリに移動: - ```bash - cd example - ``` -3. 依存関係をインストール: - ```bash - flutter pub get - ``` - -4. アプリを実行: - ```bash - flutter run - ``` - -#### サンプルアプリの機能 - -- **サーバー情報の確認**: MisskeyサーバーがOAuth 2.0をサポートしているかチェック -- **認証設定**: ホスト、クライアントID、リダイレクトURI、スコープ、コールバックスキームの設定 -- **OAuth認証フロー**: 実際のブラウザを使った認証の実行 -- **トークン管理**: 認証成功時のアクセストークンの表示・削除 -- **エラーハンドリング**: 各種エラー状況での動作確認 - -#### デフォルト設定 - -サンプルアプリには以下のデフォルト値が設定されています: - -- **ホスト**: `misskey.io` -- **クライアントID**: `https://librarylibrarian.github.io/misskey_auth/` -- **リダイレクトURI**: `https://librarylibrarian.github.io/misskey_auth/redirect.html` -- **スコープ**: `read:account write:notes` -- **コールバックスキーム**: `misskeyauth` - -これらの値は動作確認用として提供されていますが、実際のアプリ開発時は独自の値に変更してください。 -自分が対象としているサーバーでライブラリが利用できるかのチェックにも使えます。 - ### リンク - [Misskey OAuth ドキュメント](https://misskey-hub.net/ja/docs/for-developers/api/token/oauth/) diff --git a/pubspec.yaml b/pubspec.yaml index 50ed6d7..ed7c42e 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -31,7 +31,7 @@ topics: # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html # In Windows, build-name is used as the major, minor, and patch parts # of the product and file versions while build-number is used as the build suffix. -version: 0.1.2-beta +version: 0.1.3-beta environment: sdk: ">=3.5.0 <4.0.0" From 478c4056f72e0a7f94f12b015fb1fe8d31a69fa7 Mon Sep 17 00:00:00 2001 From: librarian <57712678+LibraryLibrarian@users.noreply.github.com> Date: Sat, 16 Aug 2025 04:16:19 +0900 Subject: [PATCH 4/4] =?UTF-8?q?ci:=20=E3=83=86=E3=82=B9=E3=83=88=E5=AE=9F?= =?UTF-8?q?=E8=A1=8C=E6=9D=A1=E4=BB=B6=E3=82=92=E4=BF=AE=E6=AD=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `flutter test`の実行条件として、テストファイルが存在する場合のみ実行するように変更 --- .github/workflows/ci.yaml | 1 + test/widget_test.dart | 6 ------ 2 files changed, 1 insertion(+), 6 deletions(-) delete mode 100644 test/widget_test.dart diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index c7b0b8c..bec21d2 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -59,6 +59,7 @@ jobs: - name: Analyze (fail on infos) run: dart analyze --fatal-infos - name: Test + if: ${{ hashFiles('test/**/*_test.dart') != '' }} run: flutter test example-android: diff --git a/test/widget_test.dart b/test/widget_test.dart deleted file mode 100644 index 79dc47e..0000000 --- a/test/widget_test.dart +++ /dev/null @@ -1,6 +0,0 @@ -// This is a basic Flutter widget test. -// -// To perform an interaction with a widget in your test, use the WidgetTester -// utility in the flutter_test package. For example, you can send tap and scroll -// gestures. You can also use WidgetTester to find child widgets in the widget -// tree, read text, and verify that the values of widget properties are correct.