チャットワークAPIのOAuth認証をReact Native Androidから使ってみたよ

  • 5
    Like
  • 0
    Comment

みんな大好きチャットワークが念願のWebhook&OAuthに対応しました。

あまりにも喜びが高まったので、React Native Androidで投稿サンプルを作ってみました。

2017-11-01 19_53_35.gif

もちべ

  • 通知系のbotで最も使われるであろう投稿権限(rooms.messages:write)のみのアプリケーションを作ってみたい
  • OAuthとモバイルアプリって相性悪かった気がするけどどうだったか思い出したい
  • たまにはReact NativeでAndroidだけに寄った素振りがしたい

やったこと

  • アプリケーション登録ページでサンプルアプリを登録
  • ↑でリダイレクトURLに登録したやつ(https://www.example.com/cwrnsampleredirect)をフックする設定をAndroidManifest.xmlに記載
  • App.jsにごりごり処理を書く

つくったもの

概要図

スライド1.png

コード

App.js
import React, { Component } from 'react';
import {
  View,
  Button,
  Linking,
  AsyncStorage
} from 'react-native';
import uuidv4 from "uuid/v4";

export default class App extends Component {

  state = {
    authorized: false
  }

  async componentDidMount() {
    console.log("componentDidMount");

    // intent-filterでフックしたredirect_url(認可コード付き)がここに届く
    const initialURL = await Linking.getInitialURL();
    console.log(`initialURL: ${initialURL}`);

    if (initialURL != null) {
      // ブラウザから来たっぽかったら認証を行う
      this.authorize(initialURL);
    }
  }

  /**
   * コンセント画面のURLを組み立ててブラウザにエイヤする
   */
  async startAuth() {
    const responseType = "code";
    const clientId = "くらいあんとあいでぃー";
    const scope = "rooms.messages:write";
    const state = uuidv4(); // ランダムならなんでもよかった

    // ブラウザから返ってきたときに確認したいので一旦保存
    await AsyncStorage.setItem("state", state);

    const authURL = `https://www.chatwork.com/packages/oauth2/login.php` + 
      `?response_type=${responseType}` +
      `&client_id=${clientId}` +
      `&state=${state}` +
      `&scope=${scope}`;

    if (!await Linking.canOpenURL(authURL)) {
      return;
    }

    await Linking.openURL(authURL);

    console.log("url is open.");
  }

  /**
   * 認証する
   * @param {*} initialURL intent-filterで拾ったURL
   */
  async authorize(initialURL) {

    // search paramsを抽出
    // 本当は↓みたいにしたかったけどRNにURL生えてなかった
    // const params = new URL(initialURL).searchParams;
    const searchParams = new URLSearchParams(initialURL.split("?")[1]);

    console.log(searchParams.toString());

    if (!searchParams.has('code') || !searchParams.has('state')) {
      return;
    }

    // リクエストしたときと同じ文字列で戻ってきたことを確認する
    const state = searchParams.get('state');
    const originalState = await AsyncStorage.getItem("state");
    if (state !== originalState) {
      console.log("hack?");
      return;
    }
    // 一度通れば用済みなので削除
    await AsyncStorage.removeItem("state");

    // 認証リクエスト

    const basic = 'くらいあんとあいでぃー:くらいあんとしーくれっと をBase64したやつ';

    const reqParams = {
      // 何故かこの順に並べないと通らない(invalid_grantって言われる)
      redirect_uri: 'https://www.example.com/cwrnsampleredirect',
      grant_type: 'authorization_code',
      code: searchParams.get('code')
    }

    // 涙ぐましいbody組み立て
    const reqBody = Object.keys(reqParams).map((key) => {
      return encodeURIComponent(key) + '=' + encodeURIComponent(reqParams[key]);
    }).join('&');

    const response = await fetch("https://oauth.chatwork.com/token", {
      method: 'POST',
      headers: {
        'Authorization': `BASIC ${basic}`,
        'Content-Type': 'application/x-www-form-urlencoded'
      },
      body: reqBody
    });

    console.log("response", response);

    const resBody = await response.json();

    console.log("resBody", resBody);    

    if (resBody.access_token) {
      // 成功してたら保存して画面更新
      await AsyncStorage.setItem("token", JSON.stringify(resBody));
      this.setState({authorized: true});
    } else {
      alert("認証に失敗しました");
    }

  }

  /**
   * メッセージを投稿する
   */
  async postMessage() {
    const tokenJson = await AsyncStorage.getItem('token');
    if (!tokenJson) {
      this.setState({authorized: false});
      return;
    }

    const access_token = JSON.parse(tokenJson).access_token;

    const roomId = へやばんごう;

    const reqParams = {
      body: "テスト投稿"
    }

    // 涙ぐましいbody組み立て
    const reqBody = Object.keys(reqParams).map((key) => {
      return encodeURIComponent(key) + '=' + encodeURIComponent(reqParams[key]);
    }).join('&');

    const response = await fetch(`https://api.chatwork.com/v2/rooms/${roomId}/messages`, {
      method: 'POST',
      headers: {
        'Authorization': `Bearer ${access_token}`,
        'Content-Type': 'application/x-www-form-urlencoded'
      },
      body: reqBody
    });

    alert("投稿しました");
  }

  render() {

    let MyButton;
    if (this.state.authorized) {
      MyButton = () => <Button title="投稿する" onPress={() => this.postMessage()} />
    } else {
      MyButton = () => <Button title="認証" onPress={() => this.startAuth()} />
    }

    return (
      <View style={{flex: 1, justifyContent: 'center', alignItems: 'center'}}>
        <MyButton />
      </View>
    );
  }
}
AndroidManifest.xml
<activity
  android:name=".MainActivity"
  android:label="@string/app_name"
  android:configChanges="keyboard|keyboardHidden|orientation|screenSize"
  android:windowSoftInputMode="adjustResize">
  <intent-filter>
    <action android:name="android.intent.action.MAIN" />
    <category android:name="android.intent.category.LAUNCHER" />
  </intent-filter>
  <!-- ここを追記↓ -->
  <intent-filter>
    <action android:name="android.intent.action.VIEW" />
    <category android:name="android.intent.category.DEFAULT" />
    <category android:name="android.intent.category.BROWSABLE" />
    <data android:scheme="https" android:host="www.example.com" android:path="/cwrnsampleredirect" />
  </intent-filter>
  <!-- ここを追記↑ -->
</activity>

ざっくり作ったのでところどころコピペが見えるのは勘弁してください。

たのしかったこと・よかったこと

  • async/awaitは本当にすごい。楽。
  • 単純なURLフックならAndroidManifest.xmlだけで実現できることを再確認できた。楽。
  • 投稿できた!!!!!!!!!(重要)

くるしかったこと(CW由来)

https縛り

カスタムスキーマにリダイレクトしてもらえばAndroidでもiOSでもフックできるやん?と思ってやり始めたら、https縛りがあったので雑に作れるのはAndroidだけになりました。

たったこれだけのためにUniversal Links用のファイルを置いたサーバーを立てるほどの暇はないです・・・

application/jsonで送れない

これは元々のAPIがこうだったので、仕方ないといえば仕方ないです。

リクエスト
const reqParams = {
  redirect_uri: 'https://www.example.com/cwrnsampleredirect',
  grant_type: 'authorization_code',
  code: searchParams.get('code')
}

const response = await fetch("https://oauth.chatwork.com/token", {
  method: 'POST',
  headers: {
    'Authorization': `BASIC ${basic}`,
    'Content-Type': 'application/json'
  },
  body: JSON.stringify(reqParams)
});
レスポンス
415 The request's Content-Type is not supported. Expected:↵application/x-www-form-urlencoded or multipart/form-data

application/x-www-form-urlencodedmultipart/form-data で寄越せと言われているのでその通りに送りましょう。
ドキュメントにも application/x-www-form-urlencoded でリクエストするって書いてあります。

multipart/form-data使うとapplication/jsonよこせっていわれる

なんとなくFormDataを使いたかったので、 multipart/form-data で送ることにしました。

リクエスト
const formData = new FormData();
formData.append("redirect_uri", "https://www.example.com/cwrnsampleredirect");
formData.append("grant_type", 'authorization_code');
formData.append("code", searchParams.get('code'));

const response = await fetch("https://oauth.chatwork.com/token", {
  method: 'POST',
  headers: {
    'Authorization': `BASIC ${basic}`,
    'Content-Type': 'multipart/form-data'
  },
  body: formData
});
レスポンス
415 The request's Content-Type is not supported. Expected:↵application/json

なんでや! application/json 関係ないやろ!!!!!!!

いろいろ試しましたがこの挙動はよくわかりませんでした。

実はredirect_uriが必須っぽい

ドキュメントでは

複数のredirect_uriを設定している場合は必須ですが、単一のredirect_uriしか設定されていない場合は任意です。

とあるのですが、実際には付けなかった場合に invalid_grant エラーが発生します。↓の形が最小構成ということになりますね。

const reqParams = {
  redirect_uri: 'https://www.example.com/cwrnsampleredirect',
  grant_type: 'authorization_code',
  code: searchParams.get('code')
}

コンセント画面へのURLを組み立てる場合にも似たようなルールがありますが、あちらは実際にredirect_uriなしでもちゃんと動くので、こちらが意図しない挙動なのかなと思います。

くるしかったこと(RN由来)

URL無かった

なんかURLをnewすると異常終了するんですよ。searchParamsの取り出し用に使いたかったので残念でした。

FormData使えない

途中で私も錯乱してきて、 application/x-www-form-urlencoded にFormDataを突っ込むと専用のエラーメッセージが出るという知見を得たりしました。

まとめ

とりあえず初日に動かしてみた報告な記事でした。

私がぶち当たった問題がサーバー由来なのかRN由来なのかは切り分けられてないので、ブラウザから触ればもっと罠にハマらず良い感じの挙動をするかもしれません。(ときどきfetchの実装を疑わないといけないようなプラットフォームで試してしまった私が悪い)

今後はAPIの利用者やAPIを利用したサービスが次々と立ち上がりそうで、楽しみですね!

現場からは以上です。