はじめての Meeting SDK - PKCE OAuth編 (Android)

<h2>■Zoom - PKCE OAuth とは</h2>
　既存のOAuthでは、Zoomから提供される"Authorization Code"をサーバサイドで取り扱いする必要がありましたが、PKCE OAuthでは"Code Exchange"を採用することによりSDKクライアント側の実装でAPIリクエストに必要な認証トークンの取得まで実装いただけるようになっています。
　また、Zoomユーザのメールアドレス、パスワードでZoomMeetingSDKクラインをご利用いただくことが可能になります。

(2022/06/17)

<h2>■実装の概要</h2>

ネイティブ(iOS, Android, Win, Mac)版のMeetingSDKにおいてZoomMeetingへ接続する際に、ユーザの識別にzakトークン(Zoom Access Token)を使用します。Zakトークンは、Rest_API経由で取得する必要があるため、Rest＿APIを実行するための"access_token"(認証トークン)が必要となります。
ユーザに紐ずく"access_token"を取得する為には一旦ブラウザ経由でZoomのサインインページにアクセスしてメールアドレス、パスワードを入力しZoom側で認証後、指定のリダイレクトURL宛に"Authorization Code"を元に生成いただく必要があります。また、サーバサイドで処理は必要ありませんが、実在するリダイレクト先の指定は必要となります。事前にドメインの準備は必要となります。

<h2>■実装例</h2>

⑴ 初めにアプリを起動する準備をしていきます。
MarketplaceにサインインしてサンプルのSDKをダウンロードしておきます。
＊詳しくは：<a href="https://qiita.com/yosuke-sawamura/items/de69e73e47335cd61d68">はじめての Zoom Meeting SDK - 準備編</a>）
Android Studioから新しく"Empty Activity"でプロジェクトを作成し事前にダウンロードしておいたAndroid版のSDKをインポートしていきます。
＊詳しくは：<a href="https://marketplace.zoom.us/docs/sdk/native-sdks/android/build-an-app/#import-zoom-sdk">Import the Zoom SDK libraries</a>

⑵ 識別認証で利用する対になる"code_challenge"と"code_verifier"を生成できるようにします。
```java
    public final String createCodeVerifier() {
        SecureRandom secureRandom = new SecureRandom();
        byte[] code = new byte[32];
        secureRandom.nextBytes(code);
        code_verifier = Base64.encodeToString(code, Base64.URL_SAFE | Base64.NO_WRAP | Base64.NO_PADDING);
        return code_verifier;
    }

    public final String createCodeChallenge() throws NoSuchAlgorithmException, UnsupportedEncodingException {
        byte[] codeVerifierBytes = createCodeVerifier().getBytes("US-ASCII");
        MessageDigest md = MessageDigest.getInstance("SHA-256");
        md.update(codeVerifierBytes);
        byte[] codeChallengeBytes = md.digest();
        code_challenge = Base64.encodeToString(codeChallengeBytes, Base64.URL_SAFE | Base64.NO_WRAP | Base64.NO_PADDING);
        return code_challenge;
    }
```
⑶ Zoomのサインインページで "code_challenge"を付随して認証できるようにURIを生成します。
```java
    public String createAuthorizeURI() throws UnsupportedEncodingException, NoSuchAlgorithmException {
        code_challenge = createCodeChallenge();
        Log.d(TAG, "code_challenge: " + code_challenge);
        String uriString = ＜Marketplaceで生成したAdd＿URL＞;
        Uri uri = Uri.parse(uriString)
                .buildUpon()
                .appendQueryParameter("code_challenge", code_challenge)
                .appendQueryParameter("code_challenge_method", "S256")
                .build();
        return uri.toString();
    }
```

⑷ WebViewを実装し先ほど生成したURIへアクセスできるようします。
ただし、必要なのはZoomへ認証した後の”Authorization Code”のみなのでhandlerを利用してリダイレクト先へアクセスする前にcodeだけを取得して次に繋げます。
```java
        try {
            url = createAuthorizeURI();
        } catch (UnsupportedEncodingException e) {
            e.printStackTrace();
        } catch (NoSuchAlgorithmException e) {
            e.printStackTrace();
        }

        WebView webView = (WebView) findViewById(R.id.webView1);
        webView.setWebViewClient(new WebViewClient() {
            @Override
            public boolean shouldOverrideUrlLoading(WebView view, String url) {
                final Uri uri = Uri.parse(url);
                return handleUri(uri);
            }
            private boolean handleUri(final Uri uri) {
                Log.i(TAG, "Uri = " + uri);
                final String host = uri.getHost();
                final String scheme = uri.getScheme();
                if (!host.equals(＜Marketplaceで指定したリダイレクトURLのドメイン＞)) {
                    return false; // continue loading page mainly for Zoom Authentication screen
                } else {
                    String code = uri.getQueryParameter("code");
                    Log.d(TAG, "code: " + code);
                    getAccessToken(code);
                    return true; // Stop loading once getting Authentication_Code
                }
            }
        });
        webView.getSettings().setJavaScriptEnabled(true);
        webView.getSettings().setCacheMode(WebSettings.LOAD_DEFAULT);
        webView.loadUrl(url);
```

⑸ リダイレクトされた"Authorization Code"を元に"code_verifier"を付随して"access_token"を取得します。
＊以下実際のHTTP処理は別HttpRequestClassにて実施しCallbackの様子になります。
*Callbackで取得したaccess_tokenとrefresh_tokenはその後利用できるようにSharedPreferencesに保存しておくと便利です。
```java
public void getAccessToken(String code){
        Log.d(TAG, "getAccessToken");
        Log.d(TAG, "code_verifier: " + code_verifier);
        String Url = "https://zoom.us/oauth/token?code="+ code + "&grant_type=authorization_code&redirect_uri=" + <Marketplaceで指定したURLをencodeした値> + "&code_verifier=" + code_verifier;

        String clientid = ＜Marketplaceで取得した Client ID＞;
        String clientsecret = ＜Marketplaceで取得した Client Secret＞;
        final String baseCode = clientid + ":" + clientsecret;
        final String AuthCode = Base64.encodeToString(baseCode.getBytes(), Base64.URL_SAFE | Base64.NO_WRAP);

        String AuthType = "Basic";
        String RequestMethod = "POST";
        String ContentType = "application/x-www-form-urlencoded";

        HttpRequestClass httpRequest = new HttpRequestClass();
        httpRequest.setOnCallBack(new HttpRequestClass.CallBackTask(){

            @Override
            public void CallBack(String result) {
                super.CallBack(result);
                Log.d(TAG, "getAccessToken HttpRequestClass: " + result);
                if (isJSONValid(result)){
                    try {
                        JSONObject jsonObject = new JSONObject(result);
                        String access_token = jsonObject.getString("access_token");
                        String refresh_token = jsonObject.getString("refresh_token");
                        SharedPreferences.Editor editor = pref.edit();
                        editor.putString("access_token", access_token);
                        editor.putString("refresh_token", refresh_token);
                        editor.commit();
                        getUserInfo(access_token);
                    } catch (JSONException e) {
                        System.out.println("Error " + e.toString());
                    }
                }
            }

        });

        httpRequest.execute(Url, AuthType, AuthCode, RequestMethod, ContentType);
    }
```
⑹ 取得した"access_token"を利用してユーザ情報を取得します。
＊以下実際のHTTP処理は別HttpRequestClassにて実施しCallbackの様子になります。
*Callbackで取得した"id"と"表示名"はその後利用できるようにSharedPreferencesに保存しておくと便利です。
```java
public void getUserInfo(String access_token){
        Log.d(TAG, "getUserInfo");

        String Url = "https://api.zoom.us/v2/users/me";
        final String AuthCode = access_token;
        String AuthType = "Bearer";
        String RequestMethod = "GET";
        String ContentType = "application/json";

        HttpRequestClass httpRequest = new HttpRequestClass();
        httpRequest.setOnCallBack(new HttpRequestClass.CallBackTask(){

            @Override
            public void CallBack(String result) {
                super.CallBack(result);
                Log.d(TAG, "getUserInfo HttpRequestClass: " + result);
                if (isJSONValid(result)){
                    try {
                        JSONObject jsonObject = new JSONObject(result);
                        String id = jsonObject.getString("id");
                        String first_name = jsonObject.getString("first_name");
                        String last_name = jsonObject.getString("last_name");
                        String display_name = first_name + " " + last_name;
                        Log.d(TAG, "id: " + id);
                        SharedPreferences.Editor editor = pref.edit();
                        editor.putString("id", id);
                        editor.putString("display_name", display_name);
                        editor.commit();
                        finish();
                    } catch (JSONException e) {
                        System.out.println("Error " + e.toString());
                    }
                }
            }
        });
        httpRequest.execute(Url, AuthType, AuthCode, RequestMethod, ContentType);
    }
```
⑺ ここまで取得できたところで、最後に"access_token"を利用して"zak"トークンを取得しZoomMeetingを起動します。
注意："access_token"が失効してしまっている場合には"refresh_token"を利用して別途取得し直す必要があります。
```java
public void getZakToken(String ACCESS_TOKEN){
        Log.d(TAG, "getZakToken");

        String Url = "https://api.zoom.us/v2/users/me/zak";
        final String AuthCode = ACCESS_TOKEN;
        String AuthType = "Bearer";
        String RequestMethod = "GET";
        String ContentType = "application/json";

        HttpRequestClass httpRequest = new HttpRequestClass();
        httpRequest.setOnCallBack(new HttpRequestClass.CallBackTask(){

            @Override
            public void CallBack(String result) {
                super.CallBack(result);
                Log.d(TAG, "getZakToken HttpRequestClass: " + result);
                if (isJSONValid(result)){
                    try {
                        JSONObject jsonObject = new JSONObject(result);
                        String ZOOM_ACCESS_TOKEN = jsonObject.getString("token");
                        Log.d(TAG, "zak_token: " + ZOOM_ACCESS_TOKEN);
                        prepareZoomMeeting(ZOOM_ACCESS_TOKEN);
                    } catch (JSONException e) {
                        System.out.println("Error " + e.toString());
                    }
                }
            }
        });
        httpRequest.execute(Url, AuthType, AuthCode, RequestMethod, ContentType);
    }
```
```java
        btn.setOnClickListener( v -> {
            Log.d(TAG, "button clicked: " + editTextNum.getText().toString());
            MEETING_ID = editTextNum.getText().toString();

            pref = this.getSharedPreferences("msdk_settings", Context.MODE_PRIVATE);
            USER_ID = pref.getString("id", null);
            DISPLAY_NAME = pref.getString("display_name", null);
            final String access_token = pref.getString("access_token", null);
            ZoomSDK zoomSDK = ZoomSDK.getInstance();
            if(zoomSDK.isInitialized()) {
                registerMeetingServiceListener();
                getZakToken(access_token);
            }else{
                Log.e(TAG, "zoomSDK is not initialized");
            }
        });
```


<h2>■サンプル</h2>

https://github.com/ysawamura-zoom/PKCE-ZoomMeetingSDK

<b>補足</b>
marketplace.zoom.usへサインインして「Build App」よりSDKappを登録した上では、別途Android版SDKをダウンロードする必要があります。
MarketplaceからダウンロードしたSDKのzipファイル内からそれぞれ「commonlib」、「mobilertc」に含まれるファイルをサンプル内のフォルダへコピーしてからAndroidStudioで開いでください。
また、Contrants.classにinterfeceを用意していますので、Marketaplceで採取、指定しただいたクレデンシャル、リダイレクトURLを入力してからBuildするようにしてください。
Marketplaceで指定するリダイレクトURL先での処理の必要はありませんが、何らか応答できるページを持っている必要があります。

![pxce_sample_screenshots.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2123842/a0a69a9f-655a-6603-615d-d91f500bf9c9.gif)

<h2>■その他参考資料</h2>

PKCE OAuth tutorial
　https://marketplace.zoom.us/docs/sdk/native-sdks/android/build-an-app/pkce

OAuth with Zoom
　https://marketplace.zoom.us/docs/guides/auth/oauth

はじめての Zoom Meeting SDK - 準備編
　https://qiita.com/yosuke-sawamura/items/de69e73e47335cd61d68