KAKEHASHI Tech Blog

カケハシのEngineer Teamによるブログです。

Amazon CognitoでSingle Sign Onを実現してみた

KAKEHASHI の Musubi Insight チームでエンジニアをしている横田です。

KAKEHASHI では薬剤師さん向けに Musubi という業務システムや、BI ツールの Musubi Insight という Web アプリケーションなどを提供しています。 それらは toB のサービスなので 一般的な Web サービスとの ID フェデレーションなどを利用することが難しく、Amazon Cognito によって共通の認証情報でのログインを実現しているのですが、サービスごとに毎回認証情報を入力する必要があるという課題がありました。 一度認証情報を入力すれば複数のサービスでログインしたことになるシングルサインオン(Single Sign-On)の仕組みを試験導入したので、得られた知見について紹介したいと思います。

シングルサインオンとは

シングルサインオンとは、一度ログインするだけで複数のサービスが利用可能になる仕組みで、 Yahoo! や Google のサービスをイメージしていただくとわかりやすいかと思うのですが、一度ログインするだけでメールやカレンダーなどの複数のサービスを利用することができます。

異なるサービスで同じ認証情報(ID, パスワードなど)を使い回せる仕組みのこともシングルサインオンと呼ばれることもあるのですが、その場合は、サービスごとに認証情報を入力する必要があります。(こちらは、厳密には Reduced sign-on と呼ばれます) 今回は前者の意味でのシングルサインオンの仕組みの構築について話したいと思います。

image

シングルサインオンをどうやって実現するのか

網羅的な話は、Wikipedia:シングルサインオンが詳しいです。 Web サービスにおいて、シングルサインオンを実現するためには SAML や OpenID Connect などの技術を使うと実現しやすいと考えています。ただ、あくまでそれらの技術では Reduced sign-on までしか実現できないので、足りない部分は自前で補う必要があります。その他の実現手段としては、Okta や Amazon Cognito Hosted UI などの SaaS を使うという方法もあります。また、WebAuthn やブラウザの認証情報保存機能などで、それぞれの Web サービスに認証情報を入力するコストを簡易化することで実現するというアプローチもあります。 今回は、Amazon Cognito との連携でシングルサインオンを実現したいので、Reduced Sign-On に足りない部分を加えて実現していきたいと思います。

Reduced Sign-On をシングルサインオンに使うための課題は、異なる URL ドメイン間での認証トークンの共有です。 二つの SinglePageApplication にログインするシーンを例に、具体的な流れについて説明します。まず、Amazon Cognito で ID とパスワード使って認証が成功すると JWT トークンをもらえます。 通常 Web サービスを利用するときは、そのトークンをブラウザ上(もしくは PC 上)に保存し、Web サービスに問い合わせる際にトークンをリクエストに乗せて送ります。 すると、Web サービス側はそのトークンを見て誰からのアクセスなのかを知ることができるので、そのユーザーがどのリソースにアクセスして良いのかを判断する認可処理を行うことができます。 シングルサインオンを実現するためには、異なる Web サービスを一つのトークンで利用できるようにすれば良いので、どのサイトからもアクセスできる保存場所にトークンを保存すれば良いということになります。

下の図は、Web サービス 1 と Web サービス 2 でシングルサインオンをする際のシーケンスですが、両方のアプリケーションからブラウザ上のストレージに当たる部分にアクセスできるようにする必要があります。

シーケンス図

それは簡単じゃないかと思うかもしれませんが、いくつか注意すべき箇所があります。

トークンの保存場所について

異なる Web サービスで使える保存場所として、パッと思いつくのは Cookie, localStorage, メモリ上 あたりで、あとは、ブラウザから頑張って PC 上のファイルアクセスをできるようにすれば Mac のキーチェーンなども考えられるかもしれません。 それらの保存場所にはそれぞれメリット・デメリットがあります。

Cookie を使ったアプローチでは、サーバ側ではなくクライアント側(ブラウザ側)で Cookie をセットし、Cookieをブラウザ上のストレージとして扱います。Cookie は、送り先のドメインに紐づけて情報を保存します。複数の Web サービスで共有できるようにするためには、上位レベルのドメインを一致させる必要があります。a.example.comb.example.comというドメインで提供している Web サービスであれば、.example.comのような上位ドメインに紐づけてトークンを保存すれば、シングルサインオンを実現できます。逆に、上位ドメインが全く異なる Web サービスの場合は Cookie を保存場所として使うのは難しいと考えられます。注意すべき点は、Cookie 経由での情報のクロスドメインの情報のやり取りについてはセキュリティ上の観点から扱いが厳しくなってきており、HTTPOnly, Secure, SameSite などの属性の扱いに気を配る必要があるところです。今後永劫使えるかどうかは不明なので、各ブラウザの動向にも気を配る必要があります。

LocalStorage も、セキュリティ上の観点から、送り先のドメインに紐づけて情報を保存します。Cookie と異なるのは、上位レベルのドメインでの情報共有ができないという点と、あくまでも Storage なのでリクエストに必ずしもそれらの情報が載るわけではないという点です。この情報を共有するためには、iframe を使うことができます。シンプルな実装方法としては、a.example.comb.example.comが iframe でc.example.comというドメインで提供しているページを iframe で組み込んでおいて、postMessageなどのメソッドを使って iframe 間でトークン情報をやり取りします。トークン自体はc.example.comのドメインに紐づけて保存しておけば、トークンの情報共有ができるようになるというものになります。こちらの方法は Cookie でもできます。 メモリ上に配置するケースも、ドメイン間で情報を共有することができないので、上記の iframe を使う方法を行うことになるかと思います。

PC 上のファイルに保存するアプローチは、そもそもセキュリティ上の観点でブラウザ側で禁止されていることが多く、セキュリティ面での懸念も多いですし、実装も比較的難しいと考えられます。

Amazon Cognito で実現する場合

Amazon Cognito でトークンを取得する場合、amazon-cognito-identity-jsを使うと実装が楽です。しかし、そのライブラリ単体だとトークンの保存場所については自前で実装する必要があります。amplify の Auth クラスはその辺りの保管の機能もあるのですが、デフォルト設定だとトークンを LocalStorage に保存するようになっているので、異なるドメインの Web サービス間でトークンをシェアすることはできません。 前節で説明した通り、工夫が必要になります。

Auth クラスを使ってログインするには、下記のように書いていくのですが、デフォルトだと LocalStorage に保存されます

import Auth from "@aws-amplify/auth";
Auth.configure({
  Auth: {
    region: "ap-northeast-1",
    userPoolId: "...",
    userPoolWebClientId: "...",
  },
});
Auth.signIn("ユーザーID", "パスワード").then((user) => {
  // ログイン後の処理
});

Cookie に保存するためには、下記のような設定で実装することができます。cookieStorage にはICookieStorageDataに沿って値を指定できます。

import Auth from "@aws-amplify/auth";
Auth.configure({
  Auth: {
    region: "ap-northeast-1",
    userPoolId: "...",
    userPoolWebClientId: "...",
    cookieStorage: {
      path: "/",
      expires: 1,
      sameSite: "lax",
      secure: true,
      domain: ".example.com",
    },
  },
});

その他、独自の場所に保管したい場合は、ICognitoStorageを実装したクラスを使って下記のように実装できます

import Auth from "@aws-amplify/auth";

// 下記のinterfaceを宣言する必要はないですが、参考のため。
interface ICognitoStorage {
  setItem(key: string, value: string): void;
  getItem(key: string): string | null;
  removeItem(key: string): void;
  clear(): void;
}

class CustomStorage implements ICognitoStorage {
  // 独自実装
}
Auth.configure({
  Auth: {
    region: "ap-northeast-1",
    userPoolId: "...",
    userPoolWebClientId: "...",
    storage: new CustomStorage(),
  },
});

iframe を使ってトークンを共有する場合などは、独自で Storage を使うと実装できそうです。

試験導入について

試験導入でのトークンの共有方法について

今回の試験導入では、Cookie の上位ドメインを使ってトークンを共有する方法を採用しました。 比較対象として検討したのは、localStorage + iframe経由で共有Amazon Cognito Hosted UIの二つで、 採用理由は下記 4 点の理由がありました。

1. 自前の UI が望ましい

  • Amazon Cognito Hosted UI はログイン画面とトークンの格納を担ってくれる Amazon Cognito の機能で、OAuth 2.0 を使ったシングルサインオンを簡単に実現することができるが、現時点では UI を柔軟にカスタマイズすることは難しい

2. 実装が比較的楽

  • cookie の上位ドメインでトークンを共有するには、サービス間で上位ドメインを一致させて、Auth の設定を変更するだけなので実装が楽
  • iframe 間で共有させる実装に関して、参考にできる情報が少なかった。自前で実装する場合テストなどが大変

3. iframe が特定のブラウザで使えない可能性がある

  • Safari や Opera だと iframe 間の情報共有ができない
  • 我々のサービスは Safari を許容していないが、今後使われる可能性があった

4. 上位ドメインが分かれる可能性が低いと考えられた

  • 上位ドメインを同じにできない場合は、cookie の上位ドメインでの共有ができないのだが、今回のケースでは問題はなかった

実際に試験導入して気づいたこと

Cookie の上位ドメインを使ってトークンを共有する方法を実際に運用してみて、当初想定できていなかったハマりどころなどがありました。

リクエストヘッダのサイズ上限でログインできなくなる可能性がある

amplify の Auth のライブラリを使って cookie にトークンを配置する方法の場合、トークン一つが約 1KB で、全体で約 5KB ほどのデータが Cookie に乗ることになります。 Cookie はリクエスト時に送信されてしまうので、サーバ側でリクエストヘッダの上限を設定している場合にエラーになってしまうことがありました。 具体的には S3 にホスティングしているサービスにアクセスする際に、S3 側で設定されていたリクエストヘッダの上限値に引っかかってしまうということが起こりました。

上位ドメインに Cookie が溜まっていく

先程の問題にも関わるのですが、上位ドメインに Cognito 関連の Cookie が溜まっていくという状況が発生してしまいました。

具体的には、上位ドメインにトークンを持った状態でログイン画面を開ける仕様にしていたので、同じブラウザで複数のユーザーが同時にログインした場合に昔にログインしたユーザーのトークンが Cookie に残ったままになってしまうということが起きました。

トークンを持った状態でログイン画面にアクセスすると初期画面に自動的に遷移するようにすることで複数のユーザーが同時にログインできないようにして、さらに、ログアウト時などに Cognito 関連の Cookie を削除することで複数ユーザーの情報が Cookie に共存することがないようにすることで対処しました。

複数の環境で同時にログインできない

本番環境・開発環境で上位ドメインが一致していたが、利用している UserPool が異なるため、開発環境を開いた後に本番環境を開くと、Cookie に開発環境由来の UserPool トークンを持った状態で本番環境でのリクエストをし始めるので、エラーが発生し、再ログインを求められてしまうという問題が発生しました。

こちらは、開発時に本番環境にアクセスする頻度が多くないことと、localhost で開発することが多いため許容しました。ただ、localhost の場合の cookie の取り扱いについては本番環境の上位ドメインと別扱いが必要でした。

まとめ

今回は、Amazon Cognito を使ってシングルサインオンをする方法や知見についてまとめました。 シングルサインオンを SaaS を使わず自前実装しようと考えている方に参考になれば幸いです。

株式会社カケハシでは、薬局向けに薬剤師さんや患者さんの体験が良くなるような SaaS を開発しています。 医療情報や個人情報を扱う際には認証・認可などのセキュリティ周りの技術は欠かせません。 セキュリティと利便性のトレードオフを考えるのはとても難しいですが、そういったことに興味があるエンジニアの方はぜひ仲間になっていただけますと嬉しいです!