Asana × Flutterで実現するOAuth認証

この記事は [#AsanaTogetherJP Advent Calendar 2021](https://adventar.org/calendars/6611) と、[Flutter Advent Calendar 2021](https://qiita.com/advent-calendar/2021/flutter) を
同時に提出してしまおうという ~~邪な~~ 記事です。
本記事の掲載内容は私自身の見解であり、所属する組織を代表するものではありません。
ここまでお約束 :wink:

Asanaのアドベントカレンダーを眺めていた際に、Asanaの方に「Developer向けの記事があまりないので、ぜひお願いします」
と言われたので調子に乗ってエントリーしました。
で、何をしようかなーと考えたときに、ちょうど最近趣味でFlutterを触り始めたので、
FlutterでAsanaをもっと便利に使えるアプリなんかを作ったら便利かなと思ったのがきっかけです。
Dart/Flutterについてはまだまだ勉強中ですので、あまり高度な内容は期待しないでください（とりあえず認証してAPIを呼び出せるようにすることが目標）

# この記事で実現できるようになること

- Asanaを使ったモバイルアプリにおけるOAuth認証のフローを理解できる
- FlutterにおいてAsanaでOAuthを実装する際のヒントがわかる

# 実行環境/リソースについて

以下のバージョンで動作を確認しています

- OS: Windows11 Pro
- Dart: 2.14.4
- Flutter: 2.5.3

参照したリソース

- [Asana Developers Docs - OAuth](https://developers.asana.com/docs/oauth)
- [OAuth2 - pub.dev](https://pub.dev/packages/oauth2)

# 何を実現したいか

OAuth認証でAsanaのアクセストークンを取得して、APIコールしたい

※OAuthについての説明はGoogleで検索してもらうのが良いかと思いますが、簡易的に今回利用するフローを掲載しておきます

![OAuth認証のフロー](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/280383/73eed331-7c8e-f8db-889c-e05bfa62d31b.png)

- ① ユーザ認証をコール&アプリケーションの接続を許可
    - FlutterのアプリからOAuthの認証URLを開き、Asanaの認証(ID/PWなど)を行ったのち、アプリケーションの接続を「許可」します
- ② リダイレクト
    - 認証に成功すると、リダイレクトURLに指定したURLにリダイレクトされます
    - リダイレクト時にURLにクエリパラメータでcodeが付与されています。このコードとクライアントID, シークレットを利用してBearerトークンを取得しますので取っておきます。
- ③ Bearerトークンを取得
    - APIコールに利用するBearerトークンを取得します。
    - [トークン交換エンドポイント](https://developers.asana.com/docs/token-exchange-endpoint)`https://app.asana.com/-/oauth_token` にPOSTリクエストを行うことで取得できます
- ④ Bearerトークンを使用してAPIをコール
    - `③` で取得したBearerトークンを使用してAPIコールをします
    - 利用可能なAPIはたくさんありますが、今回は一番簡単な`https://app.asana.com/api/1.0/users/me`をコールしてみます
    - そのほかのAPIについては[公式ドキュメント](https://developers.asana.com/docs)を参照してください(プランによっては利用不可能なAPIもあります)

# 準備する

AsanaやFlutterのアプリで準備することを書いておきます

## Asanaで準備するもの

AsanaのマイアプリのクライアントID, クライアントシークレットを取得します

AsanaのDeveloper Consoleを開いて、新しいアプリを作成します。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/280383/676e5e42-e94d-fed5-452f-1a3e26f5839f.png)

「アプリを作成」すると、クライアントID、クライアントシークレットが取得できるので、メモします

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/280383/b3eee493-8bcb-bef5-eee4-7c1dfe0181cc.png)

また、「リダイレクトURL」の部分には、「`http://localhost:8080/`」を設定しておきます。（ここがミソ）

## Flutterアプリで準備すること

### webview_flutterを追加する

アプリ内でAsanaの画面を表示するために、「webview_flutter」を追加します。
名前の通り、アプリ内でWebViewを利用するためのWidgetです

###  Androidの場合の作業

API Level 28以降は、http通信がデフォルトで無効化されているため、AndroidManifestなどで明示的に有効化する必要があります
https://developer.android.com/training/articles/security-config#CleartextTrafficPermitted

AndroidManifest.xmlに「android:usesCleartextTraffic="true"」を追加すると無効化されているhttp通信が行えるようになりますが、
検証要素で使用しているだけですので上記の対策をすることをお勧めします。

# 実装する

暫定完成版のコードは[こちら](https://qiita.com/ishibashi-futoshi/items/6a499ee8596a53e15e6f#%E3%81%84%E3%82%8D%E3%82%93%E3%81%AA%E3%83%91%E3%83%BC%E3%83%84%E3%81%AE%E5%AE%9F%E8%A3%85%E6%96%B9%E6%B3%95%E3%82%92%E3%81%BE%E3%81%A8%E3%82%81%E3%82%8B)に記述しております。

ポイントだけ触れていきます

## ①ユーザ認証をコール&アプリケーションの接続を許可

ログイン/許可するときは、WebViewを使用して、認証エンドポイント `https://app.asana.com/-/oauth_authorize` を開きます。

```dart:main.dart
      body: Container(
        padding: const EdgeInsets.all(10),
        child: _isBusy
            ? const WebView(
                javascriptMode: JavascriptMode.unrestricted,
                initialUrl:
                    // initialUrlに、認証エンドポイントのURLとClientId, redirect_uriなどを指定し、WebView内でAsanaの認証画面を開きます
                    'https://app.asana.com/-/oauth_authorize?response_type=code&client_id=【ClientId】&redirect_uri=http%3A%2F%2Flocalhost%3A8080%2F',
              )
            : logined
                ? Column(
                    children: [
                      Image.network(response!.data.photo['image_128x128'])
                    ],
                  )
                : Text(token),
      ),
```

## ② リダイレクト

Asanaの認証に成功すると、リダイレクト先に指定されたアドレスに自動的にリダイレクトされます。
OAuthの設定でリダイレクト先をdeep linkなどにできないので、localhostにWebServerを立ててリダイレクトを受け付けるようにしました。

```dart:server.dart
start() async {
  server = await HttpServer.bind(
      InternetAddress.loopbackIPv4, redirectServerPort,
      shared: true);

  server!.listen((request) async {
    final uri = request.uri;
    // WebViewにレスポンスを返す
    request.response
      ..statusCode = 200
      ..headers.set('Content-Type', ContentType.html.mimeType);

    await request.response.close();
    // uriからcode/errorを取得
    final code = uri.queryParameters['code'];
    final error = uri.queryParameters['error'];
    if (code != null) {
      // 結果をStreamに書き込む
      _controller.add(code);
    } else if (error != null) {
      _controller.add('');
      _controller.addError(error);
    }
  });
}
```

## ③ Bearerトークンを取得

Codeが取れたら、次はTokenを取得します。
Tokenはトークンエンドポイント `https://app.asana.com/-/oauth_token` へPOSTリクエストを送ることで取得できます。

```dart:server.dart
Future<TokenResponse> get token async {
  // Streamからcodeを取得
  final tokenCode = await code;
  // パラメータを設定し、POSTリクエストを送信する
  final response = await http.post(tokenEndpoint, body: {
    'grant_type': 'authorization_code', // 固定
    'client_id': clientId, // client Idを指定
    'client_secret': clientSecret, // client secretを指定
    'redirect_uri': 'http://localhost:8080/', // redirect uriを指定
    'code': tokenCode // リダイレクトパラメータから受けとったCodeを取得する
  });
  // Tokenをいい感じにParseしてくれる処理を書いている。（要はBearerトークンをとったりその他のパラメータを渡したりしている）
  return TokenResponse.fromMap(json.decode(response.body));
}
```

## 完成品

完成した画面の動きを見てみましょう。
デザインも何も考えられていないので微妙ですが、やりたいことはできています！
（ユーザのアイコンに指定している画像を取得・表示できました！）
![asana.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/280383/29972df3-cb28-8cd4-54cb-7d1cab529478.gif)

# 最後に

AsanaのOAuthとFlutterを使用した認証でAPIをコールできるようになりました。
作った仕組みを使用して、Asanaのオリジナルブラウザを作ってみたいと思っています。

実は、プライベートでもAsanaを家族で利用しており、共有の買い物リストやToDoリストとして使っていますが、
家計簿などにも使えないかな～などと企んでおります😎
![買い物リスト.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/280383/a89f73ba-f599-33cb-446d-d62c2bf89d0d.png)

なお、API・OAuth利用時は、[利用規約](https://asana.com/ja/terms#api-terms)をよく確認し、
~~用法・用量を守って~~ 正しく利用しましょう。