FlutterでAsana OAuthをした内容のまとめ（まとまってない）

[AsanaTogetherJP アドベントカレンダー 2021](https://adventar.org/calendars/6611)向けに、事前に調べた内容などをまとめておく場所です
~~要はチラ裏~~

## 使用したライブラリ、バージョン情報など

- バージョン
    - Dart SDK version: 2.14.4 (stable) (Wed Oct 13 11:11:32 2021 +0200) on "windows_x64"
    - Framework • revision 18116933e7 (7 weeks ago) • 2021-10-15 10:46:35 -0700
    - Pixel 4a API 30 (Emulator Only)
- - 追加したライブラリ
    - [webview_flutter](https://pub.dev/packages/webview_flutter)

## やりたいこと

- AsanaのOAuth認証をしてトークンをゲットする
- トークンを使ってAPIリクエストをする

APIリクエストでこんな情報が欲しい

```json
// https://app.asana.com/api/1.0/users/me
{
  "data": {
    "gid": "【User GID】",
    "email": "【メールアドレス】",
    "name": "Ishibashi, Futoshi",
    "photo": {
      "image_21x21": "https://s3.amazonaws.com/profile_photos/~_21x21.png",
      "image_27x27": "https://s3.amazonaws.com/profile_photos/~_27x27.png",
      "image_36x36": "https://s3.amazonaws.com/profile_photos/~_36x36.png",
      "image_60x60": "https://s3.amazonaws.com/profile_photos/~_60x60.png",
      "image_128x128": "https://s3.amazonaws.com/profile_photos/~_128x128.png"
    },
    "resource_type": "user",
    "workspaces": [
      {
        "gid": "1200040562629996",
        "name": "【ワークスペース名】",
        "resource_type": "workspace"
      }
    ]
  }
}
```

[エンドポイントに対する解説ページ](https://developers.asana.com/docs/custom-apps#:~:text=helpful%3F%20Yes%20No-,How%20to%20use%20the%20API,-Request)

## OAuth認証の流れ

[公式ドキュメント](https://developers.asana.com/docs/authentication#:~:text=from%20personal%20applications.-,OAuth,-Most%20of%20the)

- 1. Asana Developer App Console から、マイアプリを登録する
    - クライアントIDとクライアントSecretを入手
    - リダイレクトURLは諸事情により`http://localhost:8080/`にする
- 2. アプリで認証エンドポイント `https://app.asana.com/-/oauth_authorize` を開くようにする
    - [こんなやり方](https://pub.dev/packages/oauth2)もあるみたいだけどこれもまた諸事情でうまくいかなかったので独自で実装した
    - [url_launcher](https://pub.dev/packages/url_launcher)を使うやり方もあるようだが、今回は諸事情により[WebView](https://pub.dev/packages/webview_flutter)を使用
- 3. 開いたURLでAsanaにログインする
- 4. リダイレクトURLに指定したURLに飛ばされるので、飛ばした先のページでCodeを取得する
- 5. Token Exchange Endpoint `https://app.asana.com/-/oauth_token` に入手したcodeと諸々の情報を付与してPOSTリクエストする
- こんな感じの内容が返ってくるので、あとはAPIリクエストをする

こんな感じの内容

```json
{
  "access_token": "f6ds7fdsa69ags7ag9sd5a",
  "expires_in": 3600,
  "token_type": "bearer",
  "refresh_token": "hjkl325hjkl4325hj4kl32fjds",
  "data": {
    "id": "4673218951",
    "name": "Greg Sanchez",
    "email": "gsanchez@example.com"
  }
}
```

## さっきから出てくる諸事情って何

redirect_urlにCustom URL Scheme（アプリを開くDeepLink）を設定できなかったので、
url_launcherなどで開いてしまうとアプリに戻すことができなかった（私が詳しくないだけかもしれない）
ので、今回はWebViewを使って実装した。

また、そのためにHTTP Serverも一時的にローカルに立てている。

HTTP Serverの立て方はこの辺の情報を参考

- [DartでHTTPサーバを立てる](https://qiita.com/ishibashi-futoshi/items/bcaa3843dbdb485b8dc4)
- [FlutterOAuth(GitHub)](https://github.com/hitherejoe/FlutterOAuth)

→FlutterOAuthを使えばええやーんと思うかもしれないけど、
使われているコンポーネント(flutter_webview_plugin)が最新のAPIに対応していないっぽく非互換が出ていそうだったので、使うのをやめた

## いろんなパーツの実装方法をまとめる

### 画面部分

将来的にはWidgetにまとめたいと思っているが、WebViewと認証用の部品を別に管理している

```dart:main.dart
class _MyHomePageState extends State<MyHomePage> {
  bool _isBusy = false;
  void auth() async {
    final auth = AsanaAppAuth(
        authorizationEndpoint, tokenEndpoint, cliendId, clientSecret);
    // localhost:8080をリッスンするサーバを起動
    await auth.start();
    setState(() {
      _isBusy = true;
    });
    final code = await auth.token;
    setState(() {
      _isBusy = false;
    });
    // 終わったら止めておく
    await auth.stop();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text(widget.title),
      ),
      body: _isBusy ? const WebView(
        javascriptMode: JavascriptMode.unrestricted,
        initialUrl:
          'https://app.asana.com/-/oauth_authorizeresponse_type=code&client_id=【ClientId】&redirect_uri=【URL Encoded Redirect URL】'
      ) : // ログイン前に出す画面
      floatingActionButton: FloatingActionButton(
        onPressed: () {
          auth(); // 認証の部品を呼ぶfunctionを呼び出す
        },
        tooltip: 'Increment', // てきとう
        child: const Icon(Icons.add), // デフォルトの適当なアイコン
      ),
  }
}
```

### 認証部品

汚いコードだけど許して

```dart:asana_app_auth.dart
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:http/http.dart' as http;

class AsanaAppAuth {
  final Uri authorizationEndpoint;
  final Uri tokenEndpoint;
  final String clientId;
  final String clientSecret;
  final int redirectServerPort;
  final StreamController<String> _controller = StreamController();
  AsanaAppAuth(this.authorizationEndpoint, this.tokenEndpoint, this.clientId,
      this.clientSecret,
      {this.redirectServerPort = 8080});

  HttpServer? server;

  Future<String> get code => _controller.stream.first;

  Future<TokenResponse> get token async {
    final tokenCode = await code;
    // 5.の処理
    final response = await http.post(tokenEndpoint, body: {
      'grant_type': 'authorization_code',
      'client_id': clientId,
      'client_secret': clientSecret,
      'redirect_uri': 'http://localhost:8080/',
      'code': tokenCode
    });
    return TokenResponse.fromMap(json.decode(response.body));
  }

  start() async {
    server = await HttpServer.bind(
        InternetAddress.loopbackIPv4, redirectServerPort,
        shared: true);
    // 4. の処理
    server!.listen((request) async {
      final uri = request.uri;
      request.response
        ..statusCode = 200
        ..headers.set('Content-Type', ContentType.html.mimeType);

      await request.response.close();
      final code = uri.queryParameters['code'];
      final error = uri.queryParameters['error'];
      if (code != null) {
        // URLのクエリパラメータにcode=???があったらStreamに書き込む
        _controller.add(code);
      } else if (error != null) {
        _controller.add('');
        _controller.addError(error);
      }
    });
  }

  stop() async {
    await server!.close(force: true);
  }
}

class TokenResponse {
  final String accessToken;
  final int expiresIn;
  final TokenUserResponse data;
  final String refreshToken;
  TokenResponse(this.accessToken, this.expiresIn, this.data, this.refreshToken);
  static TokenResponse fromMap(Map<String, dynamic> map) {
    final data = map['data'];
    return TokenResponse(
        map['access_token'],
        map['expires_in'],
        TokenUserResponse(data['id'], data['gid'], data['name'], data['email']),
        map['refresh_token']);
  }
}

class TokenUserResponse {
  final int id;
  final String gid;
  final String name;
  final String email;
  const TokenUserResponse(this.id, this.gid, this.name, this.email);
}
```

### ユーザ情報の取得部品

最終的に、auth.tokenで取得した情報をもとに、ユーザ情報は取得できた。

```dart:me.dart
class Me {
  static Future<Response<MeResponse>> me(String token) async {
    final response = await http.get(
        Uri.parse('https://app.asana.com/api/1.0/users/me'),
        headers: {'Authorization': 'Bearer $token'});
    return MeResponse.fromJson(json.decode(response.body));
  }
}

class Response<T> {
  T data;
  Response(this.data);
}

class MeResponse {
  final String gid;
  final String email;
  final String name;
  final Map<String, dynamic> photo;
  final String resource_type;
  final List<dynamic> workspaces;
  MeResponse(this.gid, this.email, this.name, this.photo, this.resource_type,
      this.workspaces);

  static Response<MeResponse> fromJson(Map<String, dynamic> json) {
    final data = json['data'];
    final me = MeResponse(data['gid'], data['email'], data['name'],
        data['photo'], data['resource_type'], data['workspaces']);
    return Response(me);
  }
}
```

## `ERR CLEARTEXT NOT PERMITTED http://localhost:8080/` が出る場合

Androidでしか試してないですが、上記のエラーが出た場合、よくあることのようですが対処を行う必要があります。
API Level 28以降は、http通信がデフォルトで無効化されているため、AndroidManifestなどで明示的に有効化する必要があります

https://developer.android.com/training/articles/security-config#CleartextTrafficPermitted