From 99dfb263f907e3d43fe80159e6307e40480069a2 Mon Sep 17 00:00:00 2001 From: LibraryLibrarian <57712678+LibraryLibrarian@users.noreply.github.com> Date: Fri, 15 Aug 2025 06:04:18 +0900 Subject: [PATCH 1/4] =?UTF-8?q?feat:=20MiAuth=E8=BF=BD=E5=8A=A0=E5=AE=9F?= =?UTF-8?q?=E8=A3=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit OAuthについでMiAuthでの認証が可能な形へ修正を実施 --- README.md | 116 +++ android/app/src/main/AndroidManifest.xml | 8 - .../android/app/src/main/AndroidManifest.xml | 11 + example/lib/main.dart | 659 ++++++++++++------ example/pubspec.yaml | 1 + lib/misskey_auth.dart | 2 + lib/src/api/misskey_miauth_client.dart | 184 +++++ lib/src/api/misskey_oauth_client.dart | 150 +++- .../exceptions/misskey_auth_exception.dart | 98 +++ lib/src/models/auth_config.dart | 76 -- lib/src/models/auth_result.dart | 98 --- lib/src/models/miauth_models.dart | 66 ++ lib/src/models/misskey_server_info.dart | 69 -- 13 files changed, 1063 insertions(+), 475 deletions(-) create mode 100644 lib/src/api/misskey_miauth_client.dart delete mode 100644 lib/src/models/auth_config.dart delete mode 100644 lib/src/models/auth_result.dart create mode 100644 lib/src/models/miauth_models.dart delete mode 100644 lib/src/models/misskey_server_info.dart diff --git a/README.md b/README.md index a91ac16..d22b1fe 100644 --- a/README.md +++ b/README.md @@ -134,6 +134,62 @@ Add to `android/app/src/main/AndroidManifest.xml`: ``` +#### Differences in MiAuth and OAuth Configuration (Key Points for App Integration) +- This configuration (registration of the URL scheme) is done on the "app side." It is not included in the library's Manifest. +- Both methods require a "custom URL scheme" to return from an external browser to the app. +- The difference lies in how to specify "where to return from the browser." +- 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 + +```dart +import 'package:misskey_auth/misskey_auth.dart'; + +final miClient = MisskeyMiAuthClient(); +final miConfig = MisskeyMiAuthConfig( + host: 'misskey.io', + appName: 'Your App', + callbackScheme: 'yourscheme', // Scheme registered on the app side + permissions: ['read:account', 'write:notes'], +); +final miRes = await miClient.authenticate(miConfig); +``` + +##### Example of OAuth + +```dart +import 'package:misskey_auth/misskey_auth.dart'; + +final oauthClient = MisskeyOAuthClient(); +final oauthConfig = MisskeyOAuthConfig( + host: 'misskey.io', + clientId: 'https://yourpage/yourapp/', + redirectUri: 'https://yourpage/yourapp/redirect.html', + scope: 'read:account write:notes', + callbackScheme: 'yourscheme', // Scheme registered on the app side +); +final token = await oauthClient.authenticate(oauthConfig); +``` + +##### How to Support Both Methods in the Same App +- By registering the same `scheme` (e.g., `yourscheme`) in iOS's `Info.plist` and Android's `AndroidManifest.xml`, it can be shared between OAuth and MiAuth. +- If you implement the OAuth `redirect.html` to redirect to `yourscheme://oauth/callback?...`, you can reuse the same path expression (`yourscheme://oauth/callback`) for MiAuth's `callback`. +- For Android, matching only on the `scheme` is sufficient as shown below (the `host` and `path` are optional). + +```xml + + + + + + + + + + +``` + ### API Reference #### MisskeyOAuthConfig @@ -355,6 +411,66 @@ final token = await client.authenticate(config); ``` +#### MiAuth と OAuth の設定の違い(アプリ組み込み時のポイント) + +- この設定(URLスキームの登録)は「アプリ側」で行います。ライブラリ内のManifestには含めません。 +- 両方式とも、外部ブラウザからアプリへ戻すために「カスタムURLスキーム」が必要です。 +- 相違点は「ブラウザからどこに戻すか」の指定方法です。 + - OAuth: 認可サーバーからはHTTPSの`redirect_uri`に戻る必要があるため、そこに配置した`redirect.html`が最終的に`yourscheme://...`へリダイレクトしてアプリに戻します。 + - MiAuth: 認証開始URLの`callback`クエリに、最初から`yourscheme://...`を指定します(`https`は不要)。 + +##### MiAuth の例(Dart) + +```dart +import 'package:misskey_auth/misskey_auth.dart'; + +final miClient = MisskeyMiAuthClient(); +final miConfig = MisskeyMiAuthConfig( + host: 'misskey.io', + appName: 'Your App', + callbackScheme: 'yourscheme', // アプリ側で登録したスキーム + callbackHost: 'oauth', // 任意(例と合わせておく) + callbackPath: '/callback', // 任意(例と合わせておく) + permissions: ['read:account', 'write:notes'], +); +final miRes = await miClient.authenticate(miConfig); +``` + +##### OAuth の例 + +```dart +import 'package:misskey_auth/misskey_auth.dart'; + +final oauthClient = MisskeyOAuthClient(); +final oauthConfig = MisskeyOAuthConfig( + host: 'misskey.io', + clientId: 'https://yourpage/yourapp/', + redirectUri: 'https://yourpage/yourapp/redirect.html', + scope: 'read:account write:notes', + callbackScheme: 'yourscheme', // アプリ側で登録したスキーム +); +final token = await oauthClient.authenticate(oauthConfig); +``` + +##### 両方式を同一アプリでサポートするには + +- iOSの`Info.plist`・Androidの`AndroidManifest.xml`で同じ`sheme`(例: `yourscheme`)を1つ登録すれば、OAuth/MiAuthで共用可能です。 +- OAuth用の`redirect.html`は、`yourscheme://oauth/callback?...`へ飛ばす実装にしておくと、MiAuthの`callback`でも同じパス表現(`yourscheme://oauth/callback`)を使い回せます。 +- Androidは以下のように`scheme`のみのマッチで十分です(`host`や`path`は任意)。 + +```xml + + + + + + + + + + +``` + ### API リファレンス #### MisskeyOAuthConfig diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 5a66e8a..898912f 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -24,14 +24,6 @@ - - - - - - - - diff --git a/example/android/app/src/main/AndroidManifest.xml b/example/android/app/src/main/AndroidManifest.xml index 5be01ff..3670012 100644 --- a/example/android/app/src/main/AndroidManifest.xml +++ b/example/android/app/src/main/AndroidManifest.xml @@ -25,6 +25,17 @@ + + + + + + + + + + + { final _client = MisskeyOAuthClient(); + final _miClient = MisskeyMiAuthClient(); + int _currentIndex = 0; // フォームコントローラー final _hostController = TextEditingController(); @@ -38,12 +43,75 @@ class _AuthExamplePageState extends State { final _scopeController = TextEditingController(); final _callbackSchemeController = TextEditingController(); + // MiAuth 用フォーム + final _miAppNameController = TextEditingController(); + final _miPermissionsController = TextEditingController(); + final _miIconUrlController = TextEditingController(); + // 状態 - bool _isLoading = false; String? _accessToken; - String? _errorMessage; OAuthServerInfo? _serverInfo; + 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(); @@ -58,6 +126,9 @@ class _AuthExamplePageState extends State { _redirectUriController.dispose(); _scopeController.dispose(); _callbackSchemeController.dispose(); + _miAppNameController.dispose(); + _miPermissionsController.dispose(); + _miIconUrlController.dispose(); super.dispose(); } @@ -69,22 +140,36 @@ class _AuthExamplePageState extends State { '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 = ''; } Future _loadStoredToken() async { - final token = await _client.getStoredAccessToken(); - setState(() { - _accessToken = token; - }); + 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(() { - _isLoading = true; _serverInfo = null; - _errorMessage = null; }); + if (!mounted) return; + context.loaderOverlay.show(); + try { final host = _hostController.text.trim(); if (host.isEmpty) { @@ -93,28 +178,39 @@ class _AuthExamplePageState extends State { final serverInfo = await _client.getOAuthServerInfo(host); + if (!mounted) return; setState(() { _serverInfo = serverInfo; - if (serverInfo == null) { - _errorMessage = 'OAuth認証はサポートされていません(MiAuth認証を使用してください)'; - } }); + + if (serverInfo == null && mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('OAuth認証はサポートされていません(MiAuth認証を使用してください)')), + ); + } + } on MisskeyAuthException catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(_mapErrorToMessage(e))), + ); + } } catch (e) { - setState(() { - _errorMessage = 'エラー: $e'; - }); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(e.toString())), + ); + } } finally { - setState(() { - _isLoading = false; - }); + if (mounted) { + context.loaderOverlay.hide(); + } } } Future _startAuth() async { - setState(() { - _isLoading = true; - _errorMessage = null; - }); + if (!mounted) return; + context.loaderOverlay.show(); try { final config = MisskeyOAuthConfig( @@ -127,7 +223,7 @@ class _AuthExamplePageState extends State { final tokenResponse = await _client.authenticate(config); - if (tokenResponse != null) { + if (tokenResponse != null && mounted) { setState(() { _accessToken = tokenResponse.accessToken; }); @@ -138,31 +234,110 @@ class _AuthExamplePageState extends State { ); } } + } on MisskeyAuthException catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(_mapErrorToMessage(e))), + ); + } } catch (e) { - setState(() { - _errorMessage = e.toString(); - }); - if (mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text('認証エラー: $e')), ); } } finally { - setState(() { - _isLoading = false; - }); + if (mounted) { + context.loaderOverlay.hide(); + } } } - Future _clearToken() async { - await _client.clearTokens(); - await _loadStoredToken(); + Future _startMiAuth() async { + if (!mounted) return; + context.loaderOverlay.show(); + + try { + 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(); - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('トークンを削除しました')), + final config = MisskeyMiAuthConfig( + host: host, + appName: _miAppNameController.text.trim(), + callbackScheme: scheme, + permissions: permissions, + iconUrl: _miIconUrlController.text.trim().isEmpty + ? null + : _miIconUrlController.text.trim(), ); + + final res = await _miClient.authenticate(config); + + if (mounted) { + setState(() { + _accessToken = res.token; + }); + + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('MiAuth に成功しました!')), + ); + } + } + } on MisskeyAuthException catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(_mapErrorToMessage(e))), + ); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('MiAuth エラー: $e')), + ); + } + } finally { + if (mounted) { + context.loaderOverlay.hide(); + } + } + } + + 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(); + } } } @@ -178,185 +353,271 @@ class _AuthExamplePageState extends State { child: 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), - 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('トークンを削除'), - ), - ], - ], + _buildStoredTokenCard(), + const SizedBox(height: 16), + if (_currentIndex == 0) + _buildOAuthForm(context) + else if (_currentIndex == 1) + _buildMiAuthForm(context) + else + _buildServerInfoTab(context), + // 画面内のエラーカード表示は行わず、Snackbarのみで通知 + ], + ), + ), + 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: 'サーバー情報'), + ], + onDestinationSelected: (index) { + setState(() { + _currentIndex = index; + }); + }, + ), + ); + } + + 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( + 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: 16), - - // 認証設定フォーム - 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: 16), - 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), - TextField( - controller: _scopeController, - decoration: const InputDecoration( - labelText: 'スコープ', - hintText: '例: read:account write:notes', - ), - ), - const SizedBox(height: 16), - Row( - children: [ - ElevatedButton( - onPressed: _isLoading ? null : _checkServerInfo, - child: const Text('サーバー情報を確認'), - ), - const SizedBox(width: 8), - ElevatedButton( - onPressed: _isLoading ? null : _startAuth, - style: ElevatedButton.styleFrom( - backgroundColor: - Theme.of(context).colorScheme.primary, - foregroundColor: Colors.white, - ), - child: const Text('認証を開始'), - ), - ], - ), - ], - ), + TextField( + controller: _hostController, + decoration: const InputDecoration( + labelText: 'ホスト', + hintText: '例: misskey.io', ), ), - - // サーバー情報 - if (_serverInfo != null) ...[ - const SizedBox(height: 16), - 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('認証エンドポイント:\n${_serverInfo!.authorizationEndpoint}'), - const SizedBox(height: 4), - Text('トークンエンドポイント:\n${_serverInfo!.tokenEndpoint}'), - if (_serverInfo!.scopesSupported != null) ...[ - const SizedBox(height: 4), - Text( - 'サポートされているスコープ:\n${_serverInfo!.scopesSupported!.join(', ')}'), - ], - ], + 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), + TextField( + controller: _scopeController, + decoration: const InputDecoration( + labelText: 'スコープ', + hintText: '例: read:account write:notes', + ), + ), + const SizedBox(height: 16), + Row( + children: [ + ElevatedButton( + onPressed: _startAuth, + style: ElevatedButton.styleFrom( + backgroundColor: Theme.of(context).colorScheme.primary, + foregroundColor: Colors.white, ), + child: const Text('認証を開始'), ), - ), - ], + ], + ), + ], + ), + ), + ); + } - // エラーメッセージ - if (_errorMessage != null) ...[ - const SizedBox(height: 16), - Card( - color: Colors.red[50], - child: Padding( - padding: const EdgeInsets.all(16.0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Text( - 'エラー', - style: TextStyle( - fontSize: 18, - fontWeight: FontWeight.bold, - color: Colors.red, - ), - ), - const SizedBox(height: 8), - Text( - _errorMessage!, - style: const TextStyle(color: Colors.red), - ), - ], + 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), + TextField( + controller: _miPermissionsController, + decoration: const InputDecoration( + labelText: '権限(空白/カンマ区切り)', + hintText: '例: read:account write:notes', + ), + ), + const SizedBox(height: 8), + TextField( + controller: _miIconUrlController, + decoration: const InputDecoration( + labelText: 'アイコンURL(任意)', + ), + ), + const SizedBox(height: 16), + Row( + children: [ + ElevatedButton( + onPressed: _startMiAuth, + style: ElevatedButton.styleFrom( + backgroundColor: Theme.of(context).colorScheme.primary, + foregroundColor: Colors.white, ), + child: const Text('MiAuthで認証'), ), - ), - ], + ], + ), + ], + ), + ), + ); + } - // ローディング - if (_isLoading) ...[ - const SizedBox(height: 16), - const Center( - child: CircularProgressIndicator(), - ), + 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), + Text('認証エンドポイント:\n${_serverInfo!.authorizationEndpoint}'), + const SizedBox(height: 4), + Text('トークンエンドポイント:\n${_serverInfo!.tokenEndpoint}'), + if (_serverInfo!.scopesSupported != null) ...[ + const SizedBox(height: 4), + Text( + 'サポートされているスコープ:\n${_serverInfo!.scopesSupported!.join(', ')}'), ], ], ), ), ); } + + 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), + ElevatedButton( + onPressed: _checkServerInfo, + child: const Text('サーバー情報を確認'), + ), + ], + ), + ), + ), + if (_serverInfo != null) ...[ + const SizedBox(height: 16), + _buildServerInfoCard(), + ], + ], + ); + } + + // 画面内のエラーカードは廃止(Snackbarのみ使用) } diff --git a/example/pubspec.yaml b/example/pubspec.yaml index 062ef77..d2e03ed 100644 --- a/example/pubspec.yaml +++ b/example/pubspec.yaml @@ -15,6 +15,7 @@ dependencies: misskey_auth: path: ../ http: ^1.1.0 + loader_overlay: ^3.0.0 dev_dependencies: flutter_test: diff --git a/lib/misskey_auth.dart b/lib/misskey_auth.dart index 5d6c4ed..d6f9fea 100644 --- a/lib/misskey_auth.dart +++ b/lib/misskey_auth.dart @@ -1,8 +1,10 @@ // Models export 'src/models/oauth_models.dart'; +export 'src/models/miauth_models.dart'; // API export 'src/api/misskey_oauth_client.dart'; +export 'src/api/misskey_miauth_client.dart'; // Exceptions export 'src/exceptions/misskey_auth_exception.dart'; diff --git a/lib/src/api/misskey_miauth_client.dart b/lib/src/api/misskey_miauth_client.dart new file mode 100644 index 0000000..0287c0b --- /dev/null +++ b/lib/src/api/misskey_miauth_client.dart @@ -0,0 +1,184 @@ +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'; + +import '../models/miauth_models.dart'; +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'; + + MisskeyMiAuthClient({ + Dio? dio, + FlutterSecureStorage? storage, + }) : _dio = dio ?? Dio(), + _storage = storage ?? const FlutterSecureStorage(); + + /// ランダムなセッション ID を生成(URL セーフな英数字) + String generateSessionId({int length = 32}) { + const charset = + 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; + final random = Random.secure(); + return List.generate(length, (_) => charset[random.nextInt(charset.length)]) + .join(); + } + + /// MiAuth 認証を開始し、成功すればアクセストークンを返す + Future authenticate(MisskeyMiAuthConfig config) async { + try { + // 1. セッション ID を生成 + final sessionId = generateSessionId(); + if (kDebugMode) { + print('MiAuth セッション: $sessionId'); + } + + // 2. 認証 URL を構築 + final permissions = config.permissions.join(','); + final query = { + 'name': config.appName, + 'callback': config.callbackUrl, + if (permissions.isNotEmpty) 'permission': permissions, + if (config.iconUrl != null && config.iconUrl!.isNotEmpty) + 'icon': config.iconUrl!, + }; + + final authUri = Uri( + scheme: 'https', + host: config.host, + path: '/miauth/$sessionId', + queryParameters: query, + ); + + if (kDebugMode) { + print('MiAuth URL: $authUri'); + } + + // 3. ブラウザで認証ページを開く + late final String result; + try { + result = await FlutterWebAuth2.authenticate( + url: authUri.toString(), + callbackUrlScheme: config.callbackScheme, + ); + } on PlatformException catch (e) { + final code = (e.code).toLowerCase(); + if (code.contains('cancel')) { + throw const UserCancelledException(); + } + if (e.message != null && + e.message!.toLowerCase().contains('callback')) { + throw CallbackSchemeErrorException( + details: e.message, originalException: e); + } + throw AuthorizationLaunchException( + details: e.message, originalException: e); + } catch (e) { + if (e is MisskeyAuthException) rethrow; + throw AuthorizationLaunchException(details: e.toString()); + } + + if (kDebugMode) { + print('MiAuth コールバック URL: $result'); + } + + // 4. 許可後にチェック API を叩いてトークンを取得 + final checkUrl = Uri( + scheme: 'https', + host: config.host, + path: '/api/miauth/$sessionId/check', + ); + + final response = await _dio.post( + checkUrl.toString(), + options: Options(contentType: 'application/json'), + data: {}, + ); + + if (response.statusCode != 200) { + final status = response.statusCode; + String details = 'status=$status'; + final data = response.data; + if (data is Map) { + final err = data['error'] ?? data['message']; + if (err != null) { + details = '$details, $err'; + } + } + // セッション不正や期限切れなどをある程度推定 + if (status == 404 || status == 410) { + throw MiAuthSessionInvalidException(details: details); + } + throw MiAuthCheckFailedException(details: details); + } + + final body = response.data as Map; + final check = MiAuthCheckResponse.fromJson(body); + + if (!check.ok || check.token == null || check.token!.isEmpty) { + 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 成功'); + } + return MiAuthTokenResponse(token: check.token!, user: check.user); + } on MisskeyAuthException { + rethrow; + } on DioException catch (e) { + throw NetworkException(details: e.message, originalException: e); + } on PlatformException catch (e) { + final code = (e.code).toLowerCase(); + if (code.contains('cancel')) { + throw const UserCancelledException(); + } + throw AuthorizationLaunchException( + details: e.message, originalException: e); + } on FormatException catch (e) { + throw ResponseParseException(details: e.message, originalException: e); + } catch (e) { + if (kDebugMode) { + print('MiAuth エラー: $e'); + } + throw MisskeyAuthException('MiAuthでエラーが発生しました', details: e.toString()); + } + } + + /// 保存された 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 74b0912..3880c4a 100644 --- a/lib/src/api/misskey_oauth_client.dart +++ b/lib/src/api/misskey_oauth_client.dart @@ -4,8 +4,10 @@ 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'; +import '../exceptions/misskey_auth_exception.dart'; /// MisskeyのOAuth認証を管理するクライアント class MisskeyOAuthClient { @@ -34,13 +36,24 @@ class MisskeyOAuthClient { if (response.statusCode == 200) { return OAuthServerInfo.fromJson(response.data); } - return null; + if (response.statusCode == 404 || response.statusCode == 501) { + // 非対応と判断 + return null; + } + // その他のステータスはサーバー側の問題として扱う + throw ServerInfoException('OAuth情報の取得に失敗しました: ${response.statusCode}'); + } on DioException catch (e) { + if (e.response?.statusCode == 404 || e.response?.statusCode == 501) { + return null; // 非対応 + } + throw NetworkException(details: e.message, originalException: e); + } on FormatException catch (e) { + throw ResponseParseException(details: e.message, originalException: e); } catch (e) { - // OAuth非対応の場合はnullを返す if (kDebugMode) { print('OAuth情報取得エラー: $e'); } - return null; + throw ServerInfoException('OAuth情報の取得に失敗しました: $e'); } } @@ -76,7 +89,7 @@ class MisskeyOAuthClient { } final serverInfo = await getOAuthServerInfo(config.host); if (serverInfo == null) { - throw Exception('このサーバーはOAuth認証に対応していません'); + throw OAuthNotSupportedException(config.host); } if (kDebugMode) { print('認証エンドポイント: ${serverInfo.authorizationEndpoint}'); @@ -123,10 +136,30 @@ class MisskeyOAuthClient { print('コールバックURLスキーム: $callbackUrlScheme'); } - final result = await FlutterWebAuth2.authenticate( - url: authUrl.toString(), - callbackUrlScheme: callbackUrlScheme, - ); + late final String result; + try { + result = await FlutterWebAuth2.authenticate( + url: authUrl.toString(), + callbackUrlScheme: callbackUrlScheme, + ); + } on PlatformException catch (e) { + // FlutterWebAuth2 の代表的なケースをマッピング + final code = (e.code).toLowerCase(); + if (code.contains('cancel')) { + throw const UserCancelledException(); + } + // コールバックスキーム不一致/未設定の可能性 + if (e.message != null && + e.message!.toLowerCase().contains('callback')) { + throw CallbackSchemeErrorException( + details: e.message, originalException: e); + } + throw AuthorizationLaunchException( + details: e.message, originalException: e); + } catch (e) { + if (e is MisskeyAuthException) rethrow; + throw AuthorizationLaunchException(details: e.toString()); + } if (kDebugMode) { print('認証結果URL: $result'); @@ -134,6 +167,16 @@ class MisskeyOAuthClient { // 5. コールバックURLからパラメータを取得 final uri = Uri.parse(result); + // 認可サーバーからのエラー(RFC6749) + final authError = uri.queryParameters['error']; + if (authError != null && authError.isNotEmpty) { + final desc = uri.queryParameters['error_description']; + final errMsg = desc == null || desc.isEmpty + ? 'error=$authError' + : 'error=$authError, description=$desc'; + throw AuthorizationServerErrorException(details: errMsg); + } + final code = uri.queryParameters['code']; final returnedState = uri.queryParameters['state']; @@ -144,11 +187,11 @@ class MisskeyOAuthClient { // 6. stateを検証 if (returnedState != state) { - throw Exception('stateが一致しません'); + throw const StateMismatchException(); } if (code == null) { - throw Exception('認証コードが取得できませんでした'); + throw const AuthorizationCodeMissingException(); } // 7. 認証コードをトークンと交換 @@ -165,22 +208,42 @@ class MisskeyOAuthClient { ); // 8. トークンを保存 - await _saveTokens( - host: config.host, - accessToken: tokenResponse.accessToken, - refreshToken: tokenResponse.refreshToken, - expiresIn: tokenResponse.expiresIn, - ); + 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('認証成功!'); } return tokenResponse; + } on MisskeyAuthException { + rethrow; + } on DioException catch (e) { + // ネットワーク層の例外 + throw NetworkException(details: e.message, originalException: e); + } on PlatformException catch (e) { + final code = (e.code).toLowerCase(); + if (code.contains('cancel')) { + throw const UserCancelledException(); + } + throw AuthorizationLaunchException( + details: e.message, originalException: e); } catch (e) { if (kDebugMode) { print('認証エラー: $e'); } - rethrow; + // 想定外はベース例外に包む + throw MisskeyAuthException(e.toString()); } } @@ -212,14 +275,43 @@ class MisskeyOAuthClient { if (response.statusCode == 200) { return OAuthTokenResponse.fromJson(response.data); } - throw Exception('トークン交換に失敗しました: ${response.statusCode}'); - } catch (e) { - if (e is DioException) { - if (kDebugMode) { - print('DioException: ${e.response?.data}'); + final status = response.statusCode; + String message = 'トークン交換に失敗しました: $status'; + // RFC準拠のエラーフィールドがあれば詳細に含める + final data = response.data; + if (data is Map) { + final err = data['error']; + final desc = data['error_description']; + if (err != null) { + message = + '$message (error=$err${desc != null ? ', description=$desc' : ''})'; + } + } + throw TokenExchangeException(message); + } on DioException catch (e) { + if (kDebugMode) { + print('DioException: ${e.response?.data}'); + } + if (e.response != null) { + final status = e.response?.statusCode; + String message = 'トークン交換に失敗しました: $status'; + final data = e.response?.data; + if (data is Map) { + final err = data['error']; + final desc = data['error_description']; + if (err != null) { + message = + '$message (error=$err${desc != null ? ', description=$desc' : ''})'; + } } + throw TokenExchangeException(message); } - throw Exception('トークン交換中にエラーが発生しました: $e'); + // レスポンスが無い=ネットワーク層の失敗 + throw NetworkException(details: e.message, originalException: e); + } on FormatException catch (e) { + throw ResponseParseException(details: e.message, originalException: e); + } catch (e) { + throw MisskeyAuthException('トークン交換中にエラーが発生しました', details: e.toString()); } } @@ -246,11 +338,19 @@ class MisskeyOAuthClient { /// 保存されたアクセストークンを取得 Future getStoredAccessToken() async { - return await _storage.read(key: _accessTokenKey); + try { + return await _storage.read(key: _accessTokenKey); + } on PlatformException catch (e) { + throw SecureStorageException(details: e.message, originalException: e); + } } /// トークンをクリア Future clearTokens() async { - await _storage.deleteAll(); + try { + await _storage.deleteAll(); + } on PlatformException catch (e) { + throw SecureStorageException(details: e.message, originalException: e); + } } } diff --git a/lib/src/exceptions/misskey_auth_exception.dart b/lib/src/exceptions/misskey_auth_exception.dart index d495f07..2f3b74b 100644 --- a/lib/src/exceptions/misskey_auth_exception.dart +++ b/lib/src/exceptions/misskey_auth_exception.dart @@ -44,3 +44,101 @@ class TokenExchangeException extends MisskeyAuthException { class ServerInfoException extends MisskeyAuthException { const ServerInfoException(super.message); } + +/// コールバックURLスキーム(カスタムスキーム)が未設定または不一致の場合の例外 +class CallbackSchemeErrorException extends MisskeyAuthException { + const CallbackSchemeErrorException( + {String? details, Exception? originalException}) + : super('The callback URL scheme is not set or does not match.', + details: details, originalException: originalException); +} + +/// 認証UI(ブラウザ等)の起動に失敗した場合の例外 +class AuthorizationLaunchException extends MisskeyAuthException { + const AuthorizationLaunchException( + {String? details, Exception? originalException}) + : super('Failed to display the authentication screen.', + details: details, originalException: originalException); +} + +/// ユーザーが認証をキャンセルした場合の例外 +class UserCancelledException extends MisskeyAuthException { + const UserCancelledException({String? details, Exception? originalException}) + : super('The user canceled the authentication.', + details: details, originalException: originalException); +} + +/// ネットワーク層での例外(タイムアウト/オフライン/SSL等) +class NetworkException extends MisskeyAuthException { + const NetworkException({String? details, Exception? originalException}) + : super('A network error has occurred.', + details: details, originalException: originalException); +} + +/// サーバーレスポンスのパースに失敗した場合の例外 +class ResponseParseException extends MisskeyAuthException { + const ResponseParseException({String? details, Exception? originalException}) + : super('Failed to parse the server response.', + details: details, originalException: originalException); +} + +/// セキュアストレージの操作に失敗した場合の例外 +class SecureStorageException extends MisskeyAuthException { + const SecureStorageException({String? details, Exception? originalException}) + : super('Failed to operate the secure storage.', + details: details, originalException: originalException); +} + +/// OAuthのstate検証に失敗した場合の例外 +class StateMismatchException extends MisskeyAuthException { + const StateMismatchException({String? details, Exception? originalException}) + : super('The state does not match.', + details: details, originalException: originalException); +} + +/// OAuthの認可コードが取得できなかった場合の例外 +class AuthorizationCodeMissingException extends MisskeyAuthException { + const AuthorizationCodeMissingException( + {String? details, Exception? originalException}) + : super('Failed to get the authorization code.', + details: details, originalException: originalException); +} + +/// 認可サーバーがエラーを返した場合(error, error_description など) +class AuthorizationServerErrorException extends MisskeyAuthException { + const AuthorizationServerErrorException( + {String? details, Exception? originalException}) + : super('The authorization server returned an error.', + details: details, originalException: originalException); +} + +/// MiAuth がユーザーによって拒否/キャンセルされた場合の例外 +class MiAuthDeniedException extends MisskeyAuthException { + const MiAuthDeniedException({String? details, Exception? originalException}) + : super('MiAuth authentication was canceled/rejected.', + details: details, originalException: originalException); +} + +/// MiAuth のチェックAPIが失敗(非200)した場合の例外 +class MiAuthCheckFailedException extends MisskeyAuthException { + const MiAuthCheckFailedException( + {String? details, Exception? originalException}) + : super('MiAuth check failed.', + details: details, originalException: originalException); +} + +/// MiAuth のセッションが見つからない/期限切れなどで無効な場合の例外 +class MiAuthSessionInvalidException extends MisskeyAuthException { + const MiAuthSessionInvalidException( + {String? details, Exception? originalException}) + : super('MiAuth session is invalid or expired.', + details: details, originalException: originalException); +} + +/// サーバーが MiAuth に対応していない可能性がある場合の例外(必要に応じて使用) +class MiAuthNotSupportedException extends MisskeyAuthException { + const MiAuthNotSupportedException( + {String? details, Exception? originalException}) + : super('This server may not support MiAuth.', + details: details, originalException: originalException); +} diff --git a/lib/src/models/auth_config.dart b/lib/src/models/auth_config.dart deleted file mode 100644 index 83e8d9e..0000000 --- a/lib/src/models/auth_config.dart +++ /dev/null @@ -1,76 +0,0 @@ -import '../exceptions/misskey_auth_exception.dart'; - -/// Misskey認証の設定を表すクラス -class AuthConfig { - /// Misskeyサーバーのホスト(例: misskey.io) - final String host; - - /// アプリケーションのクライアントID - final String clientId; - - /// アプリケーションのクライアントシークレット - final String clientSecret; - - /// 認証完了後のリダイレクトURI - final String redirectUri; - - /// 要求するスコープ(カンマ区切り) - final String scopes; - - /// アプリケーション名 - final String appName; - - /// アプリケーションの説明 - final String? appDescription; - - /// アプリケーションのアイコンURL - final String? appIconUrl; - - /// アプリケーションの権限 - final List? permissions; - - const AuthConfig({ - required this.host, - required this.clientId, - required this.clientSecret, - required this.redirectUri, - this.scopes = 'read:account,write:notes', - required this.appName, - this.appDescription, - this.appIconUrl, - this.permissions, - }); - - /// 設定が有効かどうかを検証する - bool get isValid { - return host.isNotEmpty && - clientId.isNotEmpty && - clientSecret.isNotEmpty && - redirectUri.isNotEmpty && - appName.isNotEmpty; - } - - /// 設定の検証を行い、無効な場合は例外を投げる - void validate() { - if (!isValid) { - throw const InvalidAuthConfigException( - '認証設定が無効です。host、clientId、clientSecret、redirectUri、appNameは必須です。', - ); - } - } - - /// サーバーのベースURLを取得 - String get serverUrl { - return 'https://$host'; - } - - /// OAuth認証エンドポイントのURLを取得 - String get oauthWellKnownUrl { - return '$serverUrl/.well-known/oauth-authorization-server'; - } - - /// MiAuth認証エンドポイントのURLを取得 - String get miauthUrl { - return '$serverUrl/miauth'; - } -} diff --git a/lib/src/models/auth_result.dart b/lib/src/models/auth_result.dart deleted file mode 100644 index 2875918..0000000 --- a/lib/src/models/auth_result.dart +++ /dev/null @@ -1,98 +0,0 @@ -/// Misskey認証の結果を表すクラス -class AuthResult { - /// アクセストークン - final String accessToken; - - /// リフレッシュトークン(OAuth認証の場合) - final String? refreshToken; - - /// トークンの有効期限(秒) - final int? expiresIn; - - /// 認証方式(oauth または miauth) - final String authType; - - /// ユーザー情報 - final Map? userInfo; - - /// 認証が成功したかどうか - final bool isSuccess; - - /// エラーメッセージ(認証が失敗した場合) - final String? errorMessage; - - const AuthResult({ - required this.accessToken, - this.refreshToken, - this.expiresIn, - required this.authType, - this.userInfo, - this.isSuccess = true, - this.errorMessage, - }); - - /// 認証失敗時の結果を作成するファクトリメソッド - factory AuthResult.failure({ - required String errorMessage, - required String authType, - }) { - return AuthResult( - accessToken: '', - authType: authType, - isSuccess: false, - errorMessage: errorMessage, - ); - } - - /// OAuth認証成功時の結果を作成するファクトリメソッド - factory AuthResult.oauthSuccess({ - required String accessToken, - String? refreshToken, - int? expiresIn, - Map? userInfo, - }) { - return AuthResult( - accessToken: accessToken, - refreshToken: refreshToken, - expiresIn: expiresIn, - authType: 'oauth', - userInfo: userInfo, - isSuccess: true, - ); - } - - /// MiAuth認証成功時の結果を作成するファクトリメソッド - factory AuthResult.miauthSuccess({ - required String accessToken, - Map? userInfo, - }) { - return AuthResult( - accessToken: accessToken, - authType: 'miauth', - userInfo: userInfo, - isSuccess: true, - ); - } - - /// OAuth認証開始時の結果を作成するファクトリメソッド - factory AuthResult.oauthStarted({ - required String authUrl, - required String codeVerifier, - }) { - return AuthResult( - accessToken: '', - authType: 'oauth', - isSuccess: true, - errorMessage: null, - ); - } - - @override - String toString() { - if (isSuccess) { - return 'AuthResult(success: true, type: $authType, token: ${accessToken.substring(0, 10)}...)'; - } else { - return 'AuthResult(success: false, error: $errorMessage)'; - } - } -} diff --git a/lib/src/models/miauth_models.dart b/lib/src/models/miauth_models.dart new file mode 100644 index 0000000..626ef43 --- /dev/null +++ b/lib/src/models/miauth_models.dart @@ -0,0 +1,66 @@ +/// MiAuth 認証の設定を表すクラス +class MisskeyMiAuthConfig { + /// Misskey サーバーのホスト(例: misskey.io) + final String host; + + /// アプリケーション名(ユーザーに表示) + final String appName; + + /// カスタム URL スキーム(例: misskeyauth) + final String callbackScheme; + + /// 要求する権限一覧(例: ['read:account', 'write:notes']) + final List permissions; + + /// アプリアイコンの URL(任意) + final String? iconUrl; + + const MisskeyMiAuthConfig({ + required this.host, + required this.appName, + required this.callbackScheme, + this.permissions = const [], + this.iconUrl, + }); + + /// コールバック URL(例: misskeyauth://oauth/callback) + String get callbackUrl { + // スキームのみで十分(iOS/Android ともにスキーム一致で復帰可能) + return '$callbackScheme://'; + } +} + +/// MiAuth チェック API のレスポンス +class MiAuthCheckResponse { + final bool ok; + final String? token; + final Map? user; + + const MiAuthCheckResponse({ + required this.ok, + this.token, + this.user, + }); + + factory MiAuthCheckResponse.fromJson(Map json) { + return MiAuthCheckResponse( + ok: json['ok'] == true, + token: json['token'] as String?, + user: json['user'] as Map?, + ); + } +} + +/// MiAuth 成功時の結果(利用側が扱いやすい形) +class MiAuthTokenResponse { + /// アクセストークン + final String token; + + /// 付随するユーザー情報(任意) + final Map? user; + + const MiAuthTokenResponse({ + required this.token, + this.user, + }); +} diff --git a/lib/src/models/misskey_server_info.dart b/lib/src/models/misskey_server_info.dart deleted file mode 100644 index 980ef75..0000000 --- a/lib/src/models/misskey_server_info.dart +++ /dev/null @@ -1,69 +0,0 @@ -/// 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)'; - } -} From 6bd544a6f26e0de1dbdd367738da08bf3160c856 Mon Sep 17 00:00:00 2001 From: LibraryLibrarian <57712678+LibraryLibrarian@users.noreply.github.com> Date: Fri, 15 Aug 2025 06:19:33 +0900 Subject: [PATCH 2/4] =?UTF-8?q?add:=20README=E5=8A=A0=E7=AD=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 92 +++++++++++++++++++++++++++++++++++++++++++------------ 1 file changed, 72 insertions(+), 20 deletions(-) diff --git a/README.md b/README.md index d22b1fe..ec7f970 100644 --- a/README.md +++ b/README.md @@ -152,6 +152,7 @@ final miConfig = MisskeyMiAuthConfig( appName: 'Your App', callbackScheme: 'yourscheme', // Scheme registered on the app side permissions: ['read:account', 'write:notes'], + iconUrl: 'https://example.com/icon.png', // Optional ); final miRes = await miClient.authenticate(miConfig); ``` @@ -208,26 +209,52 @@ class MisskeyOAuthConfig { #### MisskeyOAuthClient -Main client for handling Misskey authentication. +Main client for handling Misskey OAuth authentication. ```dart class MisskeyOAuthClient { /// Authenticate with Misskey server - Future authenticate(MisskeyOAuthConfig config); + Future authenticate(MisskeyOAuthConfig config); - /// Check if server supports OAuth 2.0 - Future isOAuthSupported(String host); + /// Get OAuth server information + Future getOAuthServerInfo(String host); + + /// Get stored access token + Future getStoredAccessToken(); + + /// Clear stored tokens + Future clearTokens(); +} +``` + +#### MisskeyMiAuthClient + +Main client for handling Misskey MiAuth authentication. + +```dart +class MisskeyMiAuthClient { + /// Authenticate with Misskey server using MiAuth + Future authenticate(MisskeyMiAuthConfig config); + + /// Get stored access token + Future getStoredAccessToken(); + + /// Clear stored tokens + Future clearTokens(); } ``` ### Error Handling -The library provides custom exceptions for different error scenarios: +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. -- `MisskeyAuthException` - Base exception class -- `OAuthNotSupportedException` - When server doesn't support OAuth 2.0 -- `AuthenticationFailedException` - When authentication fails -- `TokenExchangeException` - When token exchange fails +The library includes exception classes for: +- Authentication configuration errors +- Network and connectivity issues +- OAuth and MiAuth specific errors +- User cancellation and authorization failures +- Secure storage operations +- Response parsing errors ### Common Errors @@ -429,9 +456,8 @@ final miConfig = MisskeyMiAuthConfig( host: 'misskey.io', appName: 'Your App', callbackScheme: 'yourscheme', // アプリ側で登録したスキーム - callbackHost: 'oauth', // 任意(例と合わせておく) - callbackPath: '/callback', // 任意(例と合わせておく) permissions: ['read:account', 'write:notes'], + iconUrl: 'https://example.com/icon.png', // 任意 ); final miRes = await miClient.authenticate(miConfig); ``` @@ -489,26 +515,52 @@ class MisskeyOAuthConfig { #### MisskeyOAuthClient -Misskey認証を処理するメインクライアント。 +Misskey OAuth認証を処理するメインクラス ```dart class MisskeyOAuthClient { /// Misskeyサーバーで認証を実行 - Future authenticate(MisskeyOAuthConfig config); + Future authenticate(MisskeyOAuthConfig config); + + /// OAuthサーバー情報を取得 + Future getOAuthServerInfo(String host); + + /// 保存されたアクセストークンを取得 + Future getStoredAccessToken(); + + /// 保存されたトークンを削除 + Future clearTokens(); +} +``` + +#### MisskeyMiAuthClient + +Misskey MiAuth認証を処理するメインクラス + +```dart +class MisskeyMiAuthClient { + /// MisskeyサーバーでMiAuth認証を実行 + Future authenticate(MisskeyMiAuthConfig config); + + /// 保存されたアクセストークンを取得 + Future getStoredAccessToken(); - /// サーバーがOAuth 2.0をサポートしているかチェック - Future isOAuthSupported(String host); + /// 保存されたトークンを削除 + Future clearTokens(); } ``` ### エラーハンドリング -ライブラリは様々なエラーシナリオに対応するカスタム例外を提供します: +ライブラリには以下のカテゴリの例外クラスが含まれています: +- 認証設定エラー +- ネットワーク・接続エラー +- OAuth・MiAuth固有のエラー +- ユーザーキャンセル・認可失敗 +- セキュアストレージ操作エラー +- レスポンス解析エラー -- `MisskeyAuthException` - ベース例外クラス -- `OAuthNotSupportedException` - サーバーがOAuth 2.0をサポートしていない場合 -- `AuthenticationFailedException` - 認証が失敗した場合 -- `TokenExchangeException` - トークン交換が失敗した場合 +詳細についてはpub.devのドキュメントを参考にして下さい ### よくあるエラー From e7ee0af94d609450de4ff501fe443c5e694346f3 Mon Sep 17 00:00:00 2001 From: LibraryLibrarian <57712678+LibraryLibrarian@users.noreply.github.com> Date: Fri, 15 Aug 2025 06:28:57 +0900 Subject: [PATCH 3/4] =?UTF-8?q?add:=20CHANGELOG=E5=8A=A0=E7=AD=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 432a134..66be871 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,29 @@ 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.2-beta] - 2025-08-15 + +### Added +- **MiAuth authentication support** - Alternative authentication method for Misskey servers +- **Comprehensive error handling system** - Granular exception classes for different error scenarios + +### Changed +- **Error handling architecture** - Replaced generic exceptions with specific `MisskeyAuthException` subclasses +- **OAuth client improvements** - Enhanced error mapping and better exception handling +- **MiAuth client implementation** - Complete MiAuth authentication flow with proper error handling + +### Features +- `MisskeyMiAuthClient` - Main MiAuth authentication client +- `MisskeyMiAuthConfig` - Configuration class for MiAuth authentication +- `MiAuthTokenResponse` - Response model for MiAuth authentication +- Enhanced exception classes including: + - `OAuthNotSupportedException` - Server doesn't support OAuth 2.0 + - `MiAuthDeniedException` - User denied MiAuth permission + - `NetworkException` - Network connectivity issues + - `UserCancelledException` - User cancelled authentication + - `CallbackSchemeErrorException` - URL scheme configuration errors + - And 15+ more specific exception classes + ## [0.1.1-beta] - 2025-08-12 ### Changed From 59a74f7d7bd83a6f939b367e523a0746798b4fb9 Mon Sep 17 00:00:00 2001 From: LibraryLibrarian <57712678+LibraryLibrarian@users.noreply.github.com> Date: Fri, 15 Aug 2025 06:36:53 +0900 Subject: [PATCH 4/4] =?UTF-8?q?fix:=20=E3=83=90=E3=83=BC=E3=82=B8=E3=83=A7?= =?UTF-8?q?=E3=83=B3=E3=82=B3=E3=83=BC=E3=83=89=E3=81=AE=E6=9B=B4=E6=96=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 4 ++-- pubspec.yaml | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index ec7f970..f4943cf 100644 --- a/README.md +++ b/README.md @@ -30,7 +30,7 @@ Add this to your package's `pubspec.yaml` file: ```yaml dependencies: - misskey_auth: ^0.1.1-beta + misskey_auth: ^0.1.2-beta ``` ### Quick Start @@ -334,7 +334,7 @@ MisskeyのOAuth認証・MiAuth認証をFlutterアプリで簡単に扱うため ```yaml dependencies: - misskey_auth: ^0.1.1-beta + misskey_auth: ^0.1.2-beta ``` ### クイックスタート diff --git a/pubspec.yaml b/pubspec.yaml index e9616b0..50ed6d7 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.1-beta +version: 0.1.2-beta environment: sdk: ">=3.5.0 <4.0.0"