Webアプリケーションを作成する際、複数のサブドメインに分割し、それぞれのサービスを連携させてシステムを動かすといった「マイクロサービス化」が求められることがあります。そういった時に便利な仕組みがシングルサインオン(Single Sign-On:SSO)です。

SSOとは、1度のユーザー認証によって、複数のシステムを利用開始する際に、都度認証を行う必要がなくログイン時のユーザー情報等を紐づけることができる仕組みです。

弊社で作成しているWebアプリの中にマイクロサービス化を行ったアプリがあり、認証機能はFirebase Authenticationを使って実装しています。しかし、Firebase Authenticationは単一のドメインでの認証しかサポートしていません。

ですので、アプリケーションが複数のサブドメインにまたがっている場合、ユーザーはそれぞれのサブドメインに別々にログインしなければなりません。ログアウトの場合も同様となります。これでは、良いユーザー体験とは言えません。

そこで、どのようにしてSSOを実現させたのか記事を書いてみました。

1.使用したフレームワーク/サービス

  • Single Page Application
    弊社で毎度お馴染みの、Vue.js使って実装しています。
  • Firebase Authentication
    アプリケーションに簡単に認証機能を追加することができます。
    必要に応じてソーシャルログイン、SMSなどを使った二要素認証、ゲストログインなどの自分で作るには大変な機能も全て提供してくれます。
    今回はメール / パスワード認証を使用しています。
  • Firebase Functions
    HTTPS リクエストで、バックエンド処理を自動的に実行する機能です。
    Firebase Authenticationの機能も、ここから実行しています。
    今回、Firebase Functionsには下記の3つの関数を作成します。
...cloudfunctions.net/signin // 認証処理
...cloudfunctions.net/checkAuthStatus // 認証されているか確認する
...cloudfunctions.net/signout  // ログアウト

2.ログインの流れ

今回は、ログインをするアプリと利用するサービスのアプリを分けて実装しています。また、認証継続の方法として独自ドメインの利用が前提となっています。そして、異なるサブドメインに下記のような3つのアプリがあるといった設計です。

login.example.com
app1.example.com
app2.example.com

ログインフローは下記のようになります。

ログインの流れ
  1. login.example.comアプリに移動します(メール・パスワード入力画面)
  2. 認証情報を入力します。
    ログインボタン押下で、認証処理のFunctions関数である /signin へリクエスト送信。
  3. Functions関数は情報を検証後、有効であればサインインしIDトークンを返します。
  4. 取得したIDトークンからセッションCookieを作成し、また新しいトークンが取得できます。
  5. レスポンスCookieに__sessionというkey名でトークンを設定します
    (このkey名じゃないと設定されない)

/signin へリクエストする際、ボディにメールアドレスとパスワードを付与しています。また、リクエスト方法としてaxiosを使用しています。

ユーザーがメールアドレスとパスワードを入力後、ログインボタンをクリックすると下記メソッドが呼び出されます。

login.vue

login() {
  this.$axios
    .post("/signin", {
      mail: this.mail,
      password: this.password,
    })
    .then((response) => {
      console.log("認証成功");
    })
    .catch((error) => {
      console.log("認証失敗");
    });
},

functions/index.js

exports.signin = functions.https.onRequest(async (req, res) => {
  const mail = req.body.mail;
  const password = req.body.password;

  // セッションCookieの有効期限を1日に設定
  const expiresIn = 1000 * 60 * 60 * 12;

  // Cookieの設定。httpOnlyとsecureはtrueにする
  const options =  {
    maxAge: expiresIn,
    domain: "example.com",
    httpOnly: true,
    secure: true
  };
  // email,パスワードでサインイン
  const idToken = await firebase.auth()
    .signInWithEmailAndPassword(mail, password)
    .then(userCredential => {
      return userCredential.user.getIdToken(true);
    });

  // セッションCookieの作成
  admin.auth()
    .createSessionCookie(idToken, { expiresIn })
    .then(
      (sessionCookie) => {
        // key名は__sessionにする
        res.cookie("__session", sessionCookie, options);
        res.end(JSON.stringify({ status: "success" }));
      },
      (error) => {
        res.end(JSON.stringify({ status: "error", message: error }));
      }
    );
});

3.別アプリへ遷移時の流れ

別アプリへ遷移時の流れ
  1. app1.example.comアプリに移動します。
  2. /checkAuthStatus へリクエスト送信し、認証状態確認のFunctions関数を呼び出します。
  3. セッションCookieが有効か検証し有効であればユーザーUIDを取得します。
  4. 取得できたユーザーUIDからカスタムトークンを作成します。
  5. レスポンスとしてクライアントはカスタムトークンを受け取ります。
  6. 受け取ったカスタムトークンでFirebase Authentication認証します。
  7. ページを表示します。

/checkAuthStatus へリクエスト送信するタイミングですが、vue-routerのナビゲーションガード使用時に下記メソッドを呼び出しています。

router/index.js

function sessionExists() {
  // function関数呼び出し
  axios
    .get("/checkAuthStatus")
    .then((response) => {

      // カスタムトークンで認証
      firebase
        .auth()
        .signInWithCustomToken(response.data.token)
        .then(() => {
          console.log("認証成功");
        })
    })
    .catch(error => {
      console.log("認証失敗");
    });
}

functions/index.js

exports.checkAuthStatus = functions.https.onRequest((req, res) => {
  const sessionCookie = req.cookies.__session;

  // セッション Cookie を確認
  admin.auth()
    .verifySessionCookie(sessionCookie, true)
    .then((decodedClaims) => {

      // カスタムトークンの作成
      admin.auth()
        .createCustomToken(decodedClaims.uid)
        .then((customToken) => {
          res.end(JSON.stringify({ status: "success", token: customToken }));
        })
    })
    .catch((error) => {
      res.end(JSON.stringify({ status: "error", message: error }));
    });
});

4.ログアウトの流れ

ログアウトの流れ
  1. Firebase Authenticationからサインアウトするメソッドを呼び出す。
  2. Functions関数である /signout へリクエスト送信。
  3. admin.auth().revokeRefreshTokens を使用し、セッションCookieを破棄。
  4. __sessionのCookieも削除する。

functions/index.js

exports.signout = functions.https.onRequest((req, res) => {
  const sessionCookie = req.cookies.__session;
  const options =  {
    domain: "example.com",
    httpOnly: true,
    secure: true
  };
  res.clearCookie("__session", options);

  // セッションCookieの取り消し
  admin
    .auth()
    .verifySessionCookie(sessionCookie)
    .then((decodedClaims) => {
      return admin.auth().revokeRefreshTokens(decodedClaims.sub);
    })
    .then(() => {
      res.send(JSON.stringify({ status: "success" }));
    })
    .catch((error) => {
      functions.logger.log(error);
      res.send(JSON.stringify({ status: "error", method: "verifySessionCookie", error }));
    });
});

5.Firebase Functions関数の呼び出し方法

関数の呼び出し方法ですが、下記の3種類があります。

  1. Firebaseアプリからのリクエスト (functions.https.onCall)
  2. HTTPでのリクエスト (functions.https.onRequest)
  3. HTTPでのリクエスト (Firebase Hosting連携)

1 の「アプリから関数を呼び出す」は、レスポンスとしてcookieの作成ができないため、2 か 3 のHTTP リクエストで関数を呼び出しをする必要があります。

そして2の「HTTPでのリクエスト(functions.https.onRequest)」で呼び出す場合は、CORSの設定が必要であり、面倒だったので3の「Firebase Hosting連携」で呼び出しを行っています。

Firebase Hosting連携で呼び出す場合は、firebase.jsonファイルに下記設定を追加します。

"hosting": {
  // ...

  // ホスティング内に "rewrites "属性を追加
  "rewrites": [
    {
      "source": "/signin", // このパスでリクエストした場合
      "function": "signin" // この名前のFunctions関数を呼び出す
    },
    {
      "source": "/checkAuthStatus",
      "function": "checkAuthStatus"
    },
    {
      "source": "/signout",
      "function": "signout"
    },
    {
      "source": "**",
      "destination": "/index.html"
    }
  ]
}

6.参考にした公式ドキュメント

7.最後に

今回は Firebaseを使って SSOを実現する方法について紹介しました。
すでにFirebase認証に慣れていて、ウェブアプリでFirebaseを使った経験がないと理解が難しい内容かもしれません。

が、この記事が少しでも誰かの参考になれば幸いです。