Microsoft ID プラットフォームのアクセストークンが”Invalid Signature”になる件 その1:Express + Passport編

皆さん、こんにちは。技術開発グループのn-ozawanです。
タツノオトシゴの仲間に、タツノイトコとタツノハトコがいます。

本題です。
Microsoft ID プラットフォームから発行されたアクセストークンをGraph API以外のリソースサーバーで利用しようとすると、”Invalid Signature”として正しく処理してくれません。jwt.ioでアクセストークンを解析しようとすると、同様に”Invalid Signature”が表示されてデジタル署名の検証に失敗していることが分かります。今回は”Invalid Signature”の理由と対処方法についてのお話です。

Invalid Signature

何故、デジタル署名の検証に失敗するのか?

Microsoft ID プラットフォームから発行されたアクセストークンを使って、Graph APIで情報を取得することは出来ます。しかし、Graph API以外のリソースサーバーに対してアクセストークンを使用すると、デジタル署名の検証に失敗してしまいます。この事象についてはAzure AD GitHubのIssue #609に説明がありました。

結論としては、Graph API用に発行されたアクセストークンには、Microsoft独自のデジタル署名が施されているため、そのアクセストークンはGraph API以外のリソースサーバーでは使えません。JWTのデジタル署名はJWS(RFC7515)で規定されており、Graph API用に発行されたアクセストークンは、その規定から外れたデジタル署名をしているため、厳密にはJWTではないことになります。

それはOAuth2.0の規格として問題ないの?

Graph API用に発行されたアクセストークンは、厳密にはJWTではありません。これはOAuth2.0の規格として問題ないのでしょうか?答えは「問題ありません」です。OAuth2.0はトークンの発行と使用に関する認可プロトコルです。トークンの仕様に関しては実装依存になります。なので、アクセストークンがJWT以外の別の何かでも、リソースサーバー側でそのトークンの有効性が検証出来れば、OAuth2.0の規格上、問題ないことになります。

とはいえ、jwt.ioでアクセストークンの中身が見れてしまうなど、JWTであるかのように誤解させてしまったことは申し訳ないと、Issue #609で述べられています。

Graph API以外のアクセストークンを発行する方法

ゴール

Graph API以外のリソースサーバーで利用可能な、JWT形式のアクセストークンを取得したい場合は、User.ReadなどのGraph APIに関するアクセス権限をscopeに指定しなければ良いとのことです。なんともエンジニア泣かせな仕様ですが、仕方がありません。シーケンスを2回に分けて、アクセストークンを2つ発行して貰うようにします。

1回目のシーケンスはOIDC認証を行いつつ、Graph API用のアクセストークンを発行して貰います。これは前回と同じ内容です。2回目のシーケンスでは、OAuth2.0により自前で用意したリソースサーバーへのアクセストークンを発行して貰います。1回目のシーケンスは前回を参照してください。今回は2回目のシーケンスについて述べます。

リソースサーバー用のアプリを登録する

Azureポータルからリソースサーバー用のアプリを登録します。アプリを登録した後、「APIの公開」により、スコープを追加します。

クライアント側のアクセス許可にリソースサーバーを追加する

クライアント側のアクセス許可に、先ほど登録したリソースサーバーを追加します。これで準備は完了です。

OIDC用の初期設定を行う

OIDC認証に必要な設定をPassportに渡します。

  // リソースサーバーへのアクセストークンを取得するための設定
  const oauth2Strategy = new OAuth2Strategy(
    {
      passReqToCallback: true,
      authorizationURL: `https://login.microsoftonline.com/${tenantId}/oauth2/v2.0/authorize`,
      tokenURL: `https://login.microsoftonline.com/${tenantId}/oauth2/v2.0/token`,
      clientID: "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
      clientSecret: "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
      scope: ["offline_access", "yyyyyyyy-yyyy-yyyy-yyyy-yyyyyyyyyyyy/.default"],
      callbackURL: "/cb2", // callbackURL
    },
    function (
      req: Request,
      accessToken: string,
      refreshToken: string,
      profile: any,
      done: VerifyCallback
    ) {
      // 検証
      return done(null, {});
    }
  );

OIDCではなく、OAuth2.0でトークンを発行して貰いますので、使用するクラスはOAuth2Strategyになります。1回目のシーケンスと比べて、渡す設定内容はほぼ同じです。この中で注目すべきはscopeで、yyyyyyyy-yyyy-yyyy-yyyy-yyyyyyyyyyyy/.defaultを指定しています。yyyyyyyy-yyyy-yyyy-yyyy-yyyyyyyyyyyyはリソースサーバーのクライアントIDです。yyyyyyyy-yyyy-yyyy-yyyy-yyyyyyyyyyyy/.defaultを指定することにより、ユーザーにリソースサーバーへの認可を求めると同時に、リソースサーバーへアクセス可能なアクセストークンが発行されます。

authenticateを呼び出す

リクエストを受けて、Passportのauthenticate()を処理します。4つ定義しています。

  // authenticate (1回目)
  // ユーザーの認証と、Graph APIへのアクセストークンを取得します。
  server.get("/login", passport.authenticate("openidconnect"));

  // authenticate (2回目)
  server.get(
    "/cb",
    passport.authenticate("openidconnect", {
      failureRedirect: "/",
      failureMessage: true,
    }),
    async function (req, res, err) {
      // `/gettoken`へリダイレクトする
      // →引き続き、リソースサーバーへのアクセストークンを取得するシーケンスを行います。
      res.redirect("/gettoken");
    }
  );

  // authenticate (3回目)
  // リソースサーバーへのアクセストークンを取得します。
  server.get("/gettoken", passport.authenticate("oauth2"));

  // authenticate (4回目)
  server.get(
    "/cb2",
    passport.authenticate("oauth2", {
      failureRedirect: "/",
      failureMessage: true,
    }),
    // post-request
    async function (req, res, err) {
      // `/user`へリダイレクトする
      res.redirect("/user");
    }
  );

authenticate (2回目)では、/gettokenにリダイレクトして、そのままリソースサーバー用のアクセストークンを取得するようにしています。上記は説明用に余計なコードを省いていますが、実際はCSRF対策にstateを渡して検証するようにした方が良いでしょう。

おわりに

OAuth2.0はトークンの発行と使用のプロセスであり、トークンの仕様を含め、多くが実装依存もしくは環境依存となっています。同じOIDC認証プロトコルだとしても、認証基盤が違えば、細かなところで仕様が異なることが良く分かる事例ですね。前回と同じ締めの言葉になりますが、認証基盤のクセを正しく理解しないといけませんね。

次回はこの問題をSpring Securityでどう解決するのかをテーマにしたいと思います。

ではまた。

Recommendおすすめブログ