Firebaseで入門してみるServerless Forge Apps #2 Reactで3-Legged Auth編

11 min read読了の目安(約10700字

はじめに

この記事はFirebaseで入門してみるServerless Forge Appsの記事内で作成したAPIに手を加え、Reactのフロントエンドとの組み合わせでForge Authの3-Legged Authを実現する方法を解説する内容となっています。未読の方は前回の内容もご覧頂ければと思います。

前提

  • Fireabseプロジェクトと課金の有効化
  • 前回の記事で実装したFunctions
  • node.jsをインストールしてパスを通す
  • vscode等のエディタ
  • bash環境

Forgeの3-Legged Authについて

前回の記事では説明を省略しましたが、ForgeのAuth APIには2-Legged Authと3-Legged Authと言う二つの認証方式が存在しています。

2-Legged Auth

前回解説した認証用APIである2-Legged Authでは、Web AppとForge API間の認証を目的とし、利用者を一意に特定するような認証は行いません。そんな扱いなので、認証トークンはサーバサイドで保持するなり、Forge APIを実行する度に取得/更新すれば概ね問題ありません。ただし、Forge Viewerを利用者に提供したい場合、直接ブラウザからForge APIを呼び出すことになるため、例外的にクライアントに返却する事となります。

今後の仕様変更によって状況が変わる可能性もありますが、APIがCORSに対応していないので、現状はクライアントに2-legged Authのトークンを返す必要があるのはViewerの利用時のみだと思います。

認証トークンの取得方法については前回の記事を参考にして頂ければと思いますが、Forge SDKのAuthClientTowLegged()を実行すれば取得できるので非常にシンプルです。

    // Forge SDKのAuthClientTwoLegged()でAuth Clientをインスタンス化
    const autoRefresh = true;
    const auth = new forge.AuthClientTwoLegged(
        clientId,
        clientSecret, 
        scope, 
        autoRefresh);

    // Auth Clientのauthenticate()を実行してトークンを取得する
    // authenticate()は非同期処理なのでawaitで実行
    const credentials = await auth.authenticate();

3-Legged Auth

一方、3-Legged AuthはWeb App利用者とAutodeskアカウントのユーザ権限を紐づける認証を行います。大半のForge APIについては前述の2-Legged Authで事足りるのですが、BIM360のプロジェクトの様な、ユーザ単位でアクセス制御が設定されている領域へWeb Appからアクセスする際に必要となる認証となります。

2-Legged AuthではAuthClientTowLegged()のみで認証情報が取得できるのでサーバサイドで完結する操作となりますが、3-Legged AuthではOAuth2.0の認証フローを踏む必要があり、

  1. Web AppでAuthClientThreeLegged.generateAuthUrl()を実行して認証用URLを生成
  2. 認証用URLにユーザがブラウザで遷移
  3. Autodeskドメインに遷移するのでアプリケーションに付与する権限スコープを許可
  4. 認証コード付きでコールバックURLに遷移するので認証コードをWeb Appに送信
  5. 認証コードを引数にAuthClientThreeLegged.getToken()を実行してtokenを取得

とサーバサイドとフロントエンドを跨ぐ流れとなります。

取得したトークンについては利用者の固有の権限情報が含まれるので、原則クライアントサイドでセキュアに保持する必要があります。

Web Appでのユーザー管理についての補足

前述の通り、ForgeではAuth APIを通じて認証トークンを取得する事ができます。ただし、これらのトークンはあくまでForgeからWeb Appに対して権限を委譲するための認証であるので、Web Appの利用者に対しては別途、アカウンティングや認証機構を提供する必要があります。

BIM360等のAutodeskが提供するサービスで完結する場合は別途用意しなくても済む可能性もありますが、外部サービスとの連携やアプリケーションに固有のDBを構築したい場合や、社内で利用しているIdP(googleとかMSとかLDAPとかADとか)と連携してSSOを実現したい場合にはこちらを考慮する必要があります。

なにはともあれやってみる

3-Legged Auth用のAPIを追加する

前回実装したfunctions/index.jsにAPIを2本追加します。

// ~~~~~~ 前後省略 ~~~~~~~~
// const app = express()からexports.appの間にいい感じに追記する

// 1. OAuth2.0の認証用URLを生成するAPI
app.get("/get3lUrl", cors, async (req, res) => {
  // 環境変数からForge AppのCredentialsとコールバックURLを取得する
  // OAuthのプロセスでユーザが権限を許可した後にコールバックURLへ遷移するので、Forge Appのcallbackに指定した値と同じものを指定する
  const clientId = functions.config().forge.client_id;
  const clientSecret = functions.config().forge.client_secret;
  const redirectUrl = functions.config().forge.redirect_url;
  const autoRefresh = true;

  try {
    // Forge SDKのAuthClientThreeLegged()で3-Legged用のAuth Clientをインスタンス化
    const auth = new forge.AuthClientThreeLegged(
      clientId,
      clientSecret,
      redirectUrl,
      ["data:read", "data:write"],
      autoRefresh
    );

    // 権限許可用の一時アドレスを生成
    const authUrl = await auth.generateAuthUrl();
    
  // クライアントにURLを返却して、クライアント側のフローを進める
    return res.status(200).json(authUrl);
  } catch (err) {
    console.error(err);
    res.status(500).send(err);
  }
});

// 2. クライアントで取得した認証コードから認証トークンを生成するAPI
app.get("/get3lToken/:code", cors, async (req, res) => {
  // クライアントから渡された認証コードをPath Parametersから取得
  const code = req.params.code;

  const clientId = functions.config().forge.client_id;
  const clientSecret = functions.config().forge.client_secret;
  const redirectUrl = functions.config().forge.redirect_url;
  const autoRefresh = true;
  // make the call

  try {
    const auth = new forge.AuthClientThreeLegged(
      clientId,
      clientSecret,
      redirectUrl,
      ["data:read", "data:write"],
      autoRefresh
    );

    // 認証コードをgetToken()に渡して認証トークンを生成
    const token = await auth.getToken(code);
    
    // 認証トークンと一緒にリフレッシュトークも生成しとく
    const refreshToken = await auth.refreshToken(token);

    // クライアントにトークンを返却して終わり
    return res.status(200).send({token, refreshToken});
  } catch (err) {
    console.error(err);
    res.status(500).send(err);
  }
});

環境変数のredirect_urlを修正しとく

.runtimeconfig.json

Reactのdev-serverをlocalhost:3000で起動するのでcallback先にhttp://localhost:3000/callbackを設定する。

{
  "forge": {
    "client_id": "xxxx",
    "client_secret": "xxxx",
    "redirect_url": "http://localhost:3000/callback"
  }
}

実装が終わったらローカルエミュレータを実行してみる。

npx firebase emulators:start --only functions

Reactでフロントエンドを実装

ワークスペースにReactプロジェクトを追加する。

# プロジェクトをfrontendディレクトリに作成
npx create-react-app frontend

# dev-serverのhot reloadを有効化
echo 'CHOKIDAR_USEPOLLING=true' > frontend/.env

プロジェクトの初期構成が完了したらfrontendディレクトリが作られているので配下にコンポーネントを実装する。

frontend/src/App.js

既存のファイルを上書きしてフロントエンドのメインとなるコンポーネントを実装する。

import React, { useState, useEffect } from "react";
import axios from "axios";

import {
  BrowserRouter as Router,
  Switch,
  Route,
  Link,
  Redirect,
  useLocation,
} from "react-router-dom";

import { createBrowserHistory } from "history";

import AuthStart from "./AuthStart";
import AuthCallback from "./AuthCallback";

const customHistory = createBrowserHistory();

// アプリケーションのメインとなるコンポーネント
export default function App() {
  return (
    <Router>
      <ForgeCredentials></ForgeCredentials>
    </Router>
  );
}

// 認証トークンを持っているときに表示するコンポーネント
function WithCredentials(props){  
    return <div>
      <div>3-Legged Authで認証済み</div>
        <p>3-Legged Token: {props.credentials.token.access_token}</p>
      </div>
}

// 認証トークンを持っていないときに表示するコンポーネント
function WithoutCredentials(props){
  const {setCredentials} = props;

     return <Switch>
      <Route path="/callback">
        <AuthCallback setCredentials={setCredentials} />
      </Route>
      <Route path="/">
        <AuthStart />
      </Route>
    </Switch>  
}

function ForgeRoutes(props){
  const {credentials, setCredentials} = props;
  
  return (
    credentials? 
      <WithCredentials credentials={credentials}/>: 
      <WithoutCredentials setCredentials={setCredentials}/>
  )
}

// Forgeの認証ができる便利なコンテナを定義
function ForgeCredentials() {
  // 認証が終わったトークンを保持するstateと更新用functionを定義
  const [credentials, setCredentials] = useState(null);

  return <ForgeRoutes credentials={credentials} setCredentials={setCredentials} />
}

frontend/src/AuthStart.js

新規にファイルを作成し、OAuth2.0の認証を開始するボタンコンポーネントを実装する。

import React, { useState } from "react";
import axios from "axios";

// OAuth2.0の認証フローを開始するためのボタン
export default function AuthStart() {  
    return (
      // ボタンをクリックしたら認証を開始
      <button
        onClick={async () => {
          const res = await axios.get(
            `${process.env["REACT_APP_ENDPOINT"]}/app/get3lUrl`,
          );

          // リクエストが完了するとOAuth2.0の認証用URLが返却されるのでリダイレクトさせる
          window.location.href = res.data;
        }}
      >
        3-Legged Authを開始する
      </button>
    );
  }

frontend/src/AuthCallback.jsx

新規にファイルを作成し、認証コールバックを処理するコンポーネントを実装する。

import React, { useState, useEffect } from "react";
import axios from "axios";
import { useLocation } from "react-router-dom";
import { createBrowserHistory } from "history";

const customHistory = createBrowserHistory();

// path parameters扱えるようにhooksを定義
function useQuery() {
    return new URLSearchParams(useLocation().search);
}  

// OAuth2.0の権限許可画面からコールバックされた時に認証コードを受け取って、トークンをリクエストするコンポーネント
export default function AuthCallback(props) {
    // https://xxxx.com?code=xxxxxxの形式で遷移してくるのでcodeの値をpath parametersから取得
    let code = useQuery().get("code");
  
    // propsが更新される度にcodeをWeb AppのGET:get3lTokenにリクエストする
    useEffect(() => {
      axios
        .get(
          `${process.env["REACT_APP_ENDPOINT"]}/app/get3lToken/${code}`,
        )
        .then((res) => {    
         // ブラウザのhistoryを更新
          customHistory.push("/");
         // getl3Tokenから返却されたトークンをstateにセットする
          props.setCredentials(res.data);
        });
    });
    return <h2>認証コードの検証中に表示されるやつ</h2>;
  }

フロントエンドの依存関係の解消

cd frontend
npm install --save react-router-dom axios history

フロントエンドのローカル実行

環境変数でFunctionsのエミュレータのエンドポイントを指定して開発サーバを起動する。

export REACT_APP_ENDPOINT='http://127.0.0.1:5001/FIREBASEのプロジェクトID/asia-northeast1'
npm run start

Forge Appのcallbackをローカルに向けとく

そのままだとAuthClientThreeLegged.generateAuthUrl()で指定したcallbackとForge Appに登録したcallbackの不一致でエラーとなります。

invalid callback

Forge Appの管理画面にログインして、Infomationのcallbackの項目にhttp://localhost:3000を設定する。

callbackの設定

ブラウザで動作確認

  1. http://localhost:3000をブラウザで開く
  2. 「3-Legged Authを開始する」ボタンをクリックするとAutodeskの認証ページへリダイレクトされる
  3. 「許可」をクリックするとコールバックされるので、その後hooksで認証コードが検証される
  4. コードが検証されると3-Legged Tokenが取得されてOAuth2.0の認証フローが完了

APIとフロントエンドをFirebaseプロジェクトにデプロイ

コールバックを設定

環境変数のredirect_urlをfirebase hostingのURLに設定する。

npx firebase functions:config:set forge.redirect_url="https://{FirebaseのプロジェクトID}.web.app/callback"

APIのデプロイ

APIと変数の更新を反映させるために再デプロイする。

npx firebase deploy --only functions

フロントエンドのビルド/デプロイ

cd frontend
export REACT_APP_ENDPOINT='https://asia-northeast1-{FirebaseのプロジェクトID}.cloudfunctions.net'
npm run build

firebase.json

npx run bulidでビルドしたファイル一式がfrontend/buildの配下に出力されるので、デプロイ元を指定しているpublicの値をfrontend/buildに修正する。

{
  "hosting": {
    "public": "frontend/build",
    "ignore": [
      "firebase.json",
      "**/.*",
      "**/node_modules/**"
    ],
    "rewrites": [
      {
        "source": "**",
        "destination": "/index.html"
      }
    ]
  },
}
cd ..
npx firebase deploy --only hosting

Forge AppのcallbackをFirebase HostingのURLに変更する

localhostのままなのでForge Appの管理画面からhttps://{FirebaseのプロジェクトID}.web.app/callbackを設定する。

ブラウザで確認

  1. https://{FIREBASEのプロジェクトID}.web.appをブラウザで開く
  2. 「3-Legged Authを開始する」ボタンをクリックするとAutodeskの認証ページへリダイレクトされる
  3. 「許可」をクリックするとコールバックされるので、その後hooksで認証コードが検証される
  4. コードが検証されると3-Legged Tokenが取得されてOAuth2.0の認証フローが完了

以上です。

まとめ

2-Legged Authと比べ、あまり使わない可能性が高いですが、フローが少々めんどくさかったので解説してみました。取得した認証トークンはStateに保持されるので、後はは煮るなり焼くなりしてみてください。

BIM360のISSUEを独自Appから起票したい場合なんかに必要となるっぽいので参考になれば幸いです。

なお、なんか興が乗ってしまってReact Hooksでcallbackを処理していますが、API側にcallback処理を実装して、トークンを生成してからフロントに戻ってきた方が素直だと記事を書き終えてから思いましたので、実装の一例程度に思ってもらえれば幸いです。

FirebaseもReactもForgeもやってみればそんなに難しくないので、興味がある方は是非是非チャレンジしてもらえると、頑張って記事を書いた甲斐があります。

そんな感じです。