LoginSignup
2

posted at

updated at

Organization

Auth0 SPAの認証を理解しながら、ミニマムのモック認証APIコンテナを作ってみる

この記事は 弁護士ドットコム Advent Calendar 2022 の 2 日目の記事です。

Auth0を本番サービスで採用したのですが、開発環境やCIで動かすe2eテストで毎回Auth0のテナントにアクセスされると困ります。モックコンテナにしたいですが、公式では提供していなさそうです。

そこでAuth0のリクエストを分析し、OpenID Connectの仕様を確認して、Auth0認証もどきを作成することでかなり理解が進みました。

Auth0のモック認証APIの全体コードは下記にあります。はりぼての雑な作りなので、本番で動かすのは危険です。

情報が少なかったのでまとめたので、Auth0で開発する際の参考になれば幸いです。JWT認証については勉強中なので、間違っているところがあれば教えてください。

三文まとめ

  • 認証をモックするのに必要なエンドポイントは3つ。
  • 必要なのはトークンを取得するための2つのエンドポイントと公開鍵を提供するエンドポイント
  • RS256の秘密鍵でトークンに署名し、使う側は公開鍵で検証する

Auth0のSPA + API構成での認証

基本はOAuth2のAuthorization Code Flowに沿って認証されます。

エンドポイントの仕様は下記にまとまっています。

リクエストの流れ

auth-sequence-auth-code.png
出典: Authorization Code Flow

  1. ユーザーが ログイン をクリックします
  2. Auth0 の SDK は、ユーザーを Auth0 Authorization Server /authorize にリダイレクトします
  3. Auth0 承認サーバーは、ユーザーをログインおよび承認プロンプトにリダイレクトします。
  4. ユーザーは、構成されたログイン オプションの 1 つを使用して認証します。
  5. Auth0 認可サーバーは、ユーザーを認可付きのアプリケーションにリダイレクトします。
  6. Auth0 の SDK はこれ codeを Auth0 Authorization Server に送信します /oauth/token アプリケーションのクライアント ID とクライアント シークレットと共に。
  7. Auth0 認可サーバーは、コード、クライアント ID、およびクライアント シークレットを検証します。
  8. Auth0 認可サーバーは、ID トークンとアクセス トークンで応答します。
  9. アプリケーションは、アクセス トークンを使用して API を呼び出し、ユーザーに関する情報にアクセスできます。
  10. API は要求されたデータで応答します。
  • 認証するのが /authorize エンドポイント、トークンの取得が /oauth/tokenエンドポイントです。
  • Auth0へのリクエストはiframeから行われるものと、iframe外通常のJSから実行されるものの2種類があります。

RS256

Auth0 では、JSON Web トークン (JWT) に署名するために、RS256HS256 の 2 つのアルゴリズムがサポートされてます。HS256 はクライアントのデフォルトで、RS256 は API のデフォルトです。

JWT に署名する場合の推奨されるアルゴリズムは RS256 なので、 RS256で進めます。

RS256 (SHA-256 を使用した RSA 署名) は、公開鍵と秘密鍵のペアを使う非対称アルゴリズムです。ID プロバイダーには、署名を生成するための秘密鍵があり、JWT の受信者は公開鍵を使用して JWT 署名を検証します。

仕組みと実装

公式の説明はこちらで丁寧に説明されています。

まずモック用のコンテナでは、公開鍵と秘密鍵を作成します。その後、JWTの署名の処理や公開鍵を公開するエンドポイントを作っていきます。

JWTの署名にはAuth0のjsonwebtokenを使い、APIサーバーにはexpressを使っています。

鍵の作成

まずOpenSSL コマンドを用いて、RSA 公開鍵暗号方式の秘密鍵を作成します。パスフレーズはなしでOKです。その後、自分の秘密鍵で自己署名した証明書を作成します。

openssl genrsa 2048 > private.key
openssl req -new -key private.Key -out server.csr
openssl x509 -in server.csr -out public.crt -req -signkey private.key -days 3650

アプリの初期化

バリデーションにはexpress-openapi-validatorを使っています。なので、Controllerにリクエストが来た時には、バリデーションが完了しています。

HTMLのレンダリングにはテンプレートエンジンのejsを使っています。

/app/src/index.ts
import express, { Request, Response } from "express";
import cors from "cors";
import bodyParser from "body-parser";
import morgan from "morgan";
import path from "path";
import AuthorizeController from "./controllers/AuthorizeController";
import OAuthTokenController from "./controllers/OAuthTokenController";
import * as OpenApiValidator from "express-openapi-validator";

const app = express();

app
  .options("*", cors())
  .use(morgan("combined"))
  .use(cors())
  .use(bodyParser.json())
  .use(bodyParser.urlencoded({ extended: true }))
  .use(express.static("public"));

// OpenAPIバリデーションの設定
app.use(
  OpenApiValidator.middleware({
    apiSpec: "./openapi.yml",
    validateRequests: true,
    validateResponses: true,
  })
);

app.use((err: any, req: Request, res: Response, next: any) => {
  res.status(err.status || 500).json({
    message: err.message,
    errors: err.errors,
  });
  next()
});

// テンプレートエンジン登録
app
  .set("views", path.join(__dirname, "/views"))
  .set("view engine", "ejs");

// Controller登録
app.post("/oauth/token", OAuthTokenController.post);
app.get("/authorize", AuthorizeController.get);

app.listen(80, () => {
  console.log("Auth0-Mock-Server listening on port 80!");
});

GET: /.well-known/jwks.json

Auth0 は各テナントの JWKS エンドポイントを公開しています。デフォルトは https://your-tenant.auth0.com/.well-known/jwks.json です。ここにはテナントのすべての Auth0 発行 JWT に署名するために使用される JWK が含まれます。

下記がモックサーバーのリクエストの結果です。

/.well-known/jwks.json
{
  keys: [
    {
      alg: "RS256",
      kty: "RSA",
      use: "sig",
      n: "vMPd9fkWPVbz-pc5YouWVleBRWdm-3fFYfFS3hjxRe6MSUfgPbGKk9fPJ2atJRGBDtXAmLiZ-bLbaXpkvECdO9Bdoj7bjPT_dgDybPxtctbtp9Z3MKQgVCC31RdWrfMEbKhWWqj-vKeXG8V2ve5ryrObGEPENwDmjIeBOzdSVkgla1U8iBjGBKuddwMAQhL83CCumKO-WijJJmHWGjVjaFasYUfRLVrPkcrigrwHVxJyk8zGRhyuBxUckq8CTqUOdbKoh_oaEnTgGaFQgK_h11RRqn96tPpi5BECqPAKl1umCIuSxf2pCoGW9m3bxNgD-Mak3P1Fjw4RS8cLgpgAqQ",
      e: "AQAB",
      kid: "xxxxx",
      x5t: "xxxxx",
      x5c: ["MIICljCCAX4CCQDBUFX+I486qjANBgkqhkiG9w0BAQUFADANMQswCQYDVQQGEwJKUDAeFw0yMjExMTEwNjUzMjlaFw0zMjExMDgwNjUzMjlaMA0xCzAJBgNVBAYTAkpQMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA1mXHcSQCM/0mnb1sVT66r9V91A8fsc/IN/C5Ci4hU7VsXchMAM7Hg/OmDWHNHxl94VmqVrI2NJPt9PeNGc7vB8+9A4Olsco4RoNBCQEUbSJX+KDqmkScBGLE8RkwV824xyr56ZFp5P6N1bZoizd59K2Z0PEI4/Xx9Znh/O5L/VRNW/ogh2jcK4vUVru9ywGpm9Jc1h28ZFmuuzlVbEjVrTDgo8hvOS4EpBZFJ+0CM9om3c2RzESXrlUlxyDStwPHNkmS4x27+bziNT4x7WAe2Y86nsewh1t0sZlOkcMZ+3O2kl9EZOTRQ0nY9Qis2875J8xeYP8uFdIIt/zpWiXerQIDAQABMA0GCSqGSIb3DQEBBQUAA4IBAQCC2tSrBckZlgNRZkt1e/9nRjaaa6sVCMqMGzD5ezuJsgKCPUG/DHCKNnlo8tDp7pxQK5eosh49koJDpsMWmqAruAqZ9bgyhLF3CUyz3VUvAGGGx8Cuqd8HEm8WFhDpyTcr1dlinkodoqiYc/1OQTc0KsnK/NOkeE7eUqtC9S1bKsncOgZQnYvhQm1l1mEdi8SuE5FqTZqT//ezJ3JD95vKam6KzZ4mjRr28dg5W59q1aUn36x+fcPW4GxOIlMphzKv6rdKxNHlrCuA7ANIqmdWQLkTRkz6mBYEJXnaKcK9Fjk/iOEjMGhHUxCw5utmIK3/GNPvHtd81xoGlF10Dfyo"]
    }
   ]
}

各プロパティの仕様

プロパティ名 目的
alg キーのアルゴリズム
kty 鍵のタイプ
use キーの使用方法。sigは署名の検証を表します。
x5c x509 証明書チェーン
e 標準の pem の指数
n 標準の pem のモジュラス
kid キーの一意の識別子
x5t x.509 証明書の拇印 (SHA-1 拇印)

RFC7517をベースに一部プロパティを増やしているようです。

JWKS エンドポイントを使用した JWT の検証

APIで検証する流れは下記のようになります。

  1. このエンドポイントにリクエストして、JWKS(鍵)を取得します。
  2. リクエストの Authorizationヘッダーから JWT を抽出します。
  3. JWT をデコードし、ヘッダーからkidプロパティを取得します。
  4. 1で取得したJWKSから、一致するkid プロパティを持つ署名検証キーを見つけます。
  5. x5c プロパティを使用して、JWT 署名の検証に使用される証明書を作成します。
  6. JWT に想定されるオーディエンス、発行者、有効期限などが含まれていることを確認します。

この後実装するトークンの署名の時のkidとJWTSのkidを合わせる必要があります。また、公開鍵のデータは x5cに含まれます。

実装

expressの実行前に静的ファイルを/app/public/.well-known/jwks.jsonに吐き出します。

実装する時には、動作に影響のないe, nなどは適当にダミーを入れてます。

/app/src/utils/jwt.ts
import fs from "fs";
import jsonwebtoken from "jsonwebtoken";

const privateKey = fs.readFileSync("/app/config/private.key");

class JWT {
  static readonly KID = "xxxxx";

  static readonly alg = "RS256";

  static sign = (params: any) =>
    jsonwebtoken.sign(params, privateKey, {
      algorithm: JWT.alg,
      // ヘッダーにkidを含める
      header: {
        alg: JWT.alg,
        typ: "JWT",
        kid: JWT.KID,
      },
    });
}

export default JWT;

/app/bin/createPubJWTKey.ts
import fs from "fs";
import JWT from "../src/utils/jwt";

// 公開鍵を読み込んで改行を削除しフォーマットする
const pubKeyText = fs
  .readFileSync("/app/config/public.crt", "utf8")
  .replace(/\r?\n/g, '')
  .replace('-----BEGIN CERTIFICATE-----', '')
  .replace('-----END CERTIFICATE-----', '');

// Jsonのbodyを作る
const result = {
  keys: [
    {
      alg: JWT.alg,
      kty: "RSA",
      use: "sig",
      n: "vMPd9fkWPVbz-pc5YouWVleBRWdm-3fFYfFS3hjxRe6MSUfgPbGKk9fPJ2atJRGBDtXAmLiZ-bLbaXpkvECdO9Bdoj7bjPT_dgDybPxtctbtp9Z3MKQgVCC31RdWrfMEbKhWWqj-vKeXG8V2ve5ryrObGEPENwDmjIeBOzdSVkgla1U8iBjGBKuddwMAQhL83CCumKO-WijJJmHWGjVjaFasYUfRLVrPkcrigrwHVxJyk8zGRhyuBxUckq8CTqUOdbKoh_oaEnTgGaFQgK_h11RRqn96tPpi5BECqPAKl1umCIuSxf2pCoGW9m3bxNgD-Mak3P1Fjw4RS8cLgpgAqQ",
      e: "AQAB",
      kid: JWT.KID,
      x5t: JWT.KID,
      x5c: [pubKeyText],
    },
  ],
};

const dirPath = "/app/public/.well-known";

// ディレクトリがなければ作成
if(!fs.existsSync(dirPath)) {
    fs.mkdirSync(dirPath,  { recursive: true })
}

// jsonファイルを出力
fs.writeFileSync(`${dirPath}/jwks.json`, JSON.stringify(result));

GET: /authorize

Web Messaging APIでのサイレント認証

クエリーパラメーターのresponse_modeが、queryならばredirectしてクエリーパラメータで結果を返し、web_messageならばWeb Messaging API を使ったHTMLを返します。

response_modeのweb_message は 画面を表示させないためのオプションのprompt=noneと一緒に使われるSPAでサイレント認証を実現する仕組みです。

サイレント認証する場合、下記のような流れになります。

  1. ユーザに見えない iframe を生成する
  2. iframe から /authorize へリクエストしてHTMLのレスポンスを受け取る
  3. レスポンスのHTMLを使って認可コードを Web Message 経由で取得

こちらが非常にわかりやすいです。

Cookie

Auth0では、さまざまなcookieが払い出されます。

名前 目的
auth0 Auth0 セッション層を実装するために使用されます。
auth0_compat sameSite=Noneこの属性をサポートしていないブラウザーでのシングル サインオン用のフォールバック Cookie
did 攻撃保護のためのデバイス識別
did_compat sameSite=None属性をサポートしていないブラウザーで異常を取得するためのフォールバック Cookie

シングルサインや攻撃防御の代表的なCookieは上記ですが、詳しくはこちらを確認ください。

今回のモックコンテナではセッションの維持のため雑に固定値で付与します。localhost:1080とかで動かすと、PORT番号の指定が必要になるので使いづらくなるので。

実装

クエリーパラメーターのresponse_modeを見て、queryならばクエリーパラメータをつけてredirectして、web_messageならばWeb Messaging API を使ったHTMLを返します。

/app/src/controllers/AuthorizeController.ts
import { Request, Response } from "express";
import TokenCache from "../models/TokenCache";

class AuthorizeController {
  static get = (req: Request, res: Response) => {
    const currentDate = new Date();
    currentDate.setDate(currentDate.getDate() + 3);

    const cache = new TokenCache();
    // tokenを発行する時につかうので、node-cacheに雑に保存します。
    cache.set({
      nonce: req.query["nonce"] as string,
      scope: req.query["scope"] as string,
      audience: req.query["audience"] as string || ''
    })

    const params = {
      code: "ZtVJQyXf9eOOQJi5h_xpY6CSgEcr092_3TTWT28zsy42z",
      state: req.query!["state"] as string,
    };

    // HTMLで返すか、クエリーで返すか分岐します。
    switch (req.query["response_mode"]) {
      case "query":
        return AuthorizeController.createRedirectResponse(params, req, res);
      case "web_message":
        return AuthorizeController.createHTMLResponse(params, req, res);
      default:
        return res.status(400);
    }
  };

  private static createRedirectResponse = (
    params: { code: string; state: string },
    req: Request,
    res: Response
  ) => {
    const urlSearchParam = new URLSearchParams(params).toString();
    res.redirect(`${req.query!["redirect_uri"]}?${urlSearchParam.toString()}`);
  };

  private static createHTMLResponse = (
    params: { code: string; state: string },
    req: Request,
    res: Response
  ) => {
    const redirectUrl = new URL(req.query!["redirect_uri"] as string)
    const targetOrigin = redirectUrl.origin
    res.render('index', {...params, targetOrigin })
  };
}

export default AuthorizeController;
/app/src/views/index.ejs
<!DOCTYPE html>
<html>
  <head>
    <title>Authorization Response</title>
  </head>
  <body>
    <script type="text/javascript">
      (function (window, document) {
        var targetOrigin = "<%= targetOrigin %>";
        var webMessageRequest = {};
        var authorizationResponse = {
          type: "authorization_response",
          response: {
            code: "<%= code %>",
            state: "<%= state %>",
          },
        };
        var mainWin = window.opener ? window.opener : window.parent;
        if (
          webMessageRequest["web_message_uri"] &&
          webMessageRequest["web_message_target"]
        ) {
          window.addEventListener("message", function (evt) {
            if (evt.origin != targetOrigin) return;
            switch (evt.data.type) {
              case "relay_response":
                var messageTargetWindow =
                  evt.source.frames[webMessageRequest["web_message_target"]];
                if (messageTargetWindow) {
                  messageTargetWindow.postMessage(
                    authorizationResponse,
                    webMessageRequest["web_message_uri"]
                  );
                  window.close();
                }
                break;
            }
          });
          mainWin.postMessage(
            {
              type: "relay_request",
            },
            targetOrigin
          );
        } else {
          mainWin.postMessage(authorizationResponse, targetOrigin);
        }
      })(this, this.document);
    </script>
  </body>
</html>

POST: /oauth/token/

レスポンス

token取得のエンドポイントです。access_tokenid_tokenがふくまれます.

モックコンテナでのレスポンスのイメージはこのようになります。

{
  access_token: "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6Inh4eHh4In0.eyJodHRwOi8vbG9jYWxob3N0OjEwODAvZW1haWwiOiJiZW5nbzRAZXhhbXBsZS5jb20iLCJpc3MiOiJodHRwOi8vbG9jYWxob3N0OjEwODAvIiwic3ViIjoiYXV0aDB8NjM0Njc3YTA1YjA0Mjk4MDFlZTA1YmU1IiwiYXVkIjpbImh0dHA6Ly9sb2NhbGhvc3Q6MTgwODAiLCJ4eHgiXSwiaWF0IjoxNjY5ODcyNTUxLCJleHAiOjE2Njk5NTg5NTEsImF6cCI6Inh4eHgiLCJzY29wZSI6Im9wZW5pZCBwcm9maWxlIGVtYWlsIiwicGVybWlzc2lvbnMiOltdfQ.uIVsA3RVPEAvICih8rZBlGLtIgQDj_FW2o5M1YSB4yryd_Bjd7gnTB7EcR45dh7FcBlTXI48CznZ6WmMA5TMaALR7Ptp604q-sPgepEodcLPvl_jgAnq0vMb0ITLlcfA0tisknjoQ0OT32cSy8rnbc5YTU7lX9hv84nRd5zWBLKUZS_oqlMMXXXPsHSoDsi0uzL7WON2NKFd2HkxxqfTbJmXLULhOfaKSBl3Tlwrf2QhFm3ymQzmtkNQh6Zqw5Vbn5OIq-ZMCipQ_ccIYTFjQrGdYmy-P92a3opN4O8RX5is8CCB9rP5pqNZCoo2dZ58OgtB-8yIlhP-JFglzaiL-g",
  expires_in: 86400,
  id_token: "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6Inh4eHh4In0.eyJuaWNrbmFtZSI6IiIsIm5hbWUiOiJiZW5nbzRAZXhhbXBsZS5jb20iLCJwaWN0dXJlIjoiIiwidXBkYXRlZF9hdCI6IjIwMjItMTEtMTBUMDg6MzQ6NTQuMzY4WiIsImVtYWlsIjoiYmVuZ280QGV4YW1wbGUuY29tIiwiZW1haWxfdmVyaWZpZWQiOnRydWUsImlzcyI6Imh0dHA6Ly9sb2NhbGhvc3Q6MTA4MC8iLCJzdWIiOiJhdXRoMHw2MzQ2NzdhMDViMDQyOTgwMWVlMDViZTUiLCJhdWQiOiJ4eHh4IiwiaWF0IjoxNjY5ODcyNTUxLCJleHAiOjE2Njk5NTg5NTEsInNpZCI6ImtNQXNhb1B0bG5zZXZuVFNmRDg3R1Q1eDNCLXd4SWJsIiwibm9uY2UiOiJlRGczVlRWVWFHbHhZVVp5T0hCbGJETnFRa0ZtVTB4d1RIVlBja0Y0UlM1cVJHZG9SWEk1V2s1TVJ3PT0ifQ.MH9lQgAukP8-zy85RxsRqgI5zM399_eDE2vqFMnmNXq0t5Kfi0kRp0OF8q_j8R3b_ONQZIRZeW9atCHOupJtWKhVYyMY5ZM2REH5t1xBq4oDuuE4mNhTL5eQ08zU8kwRkduNVMTXfmy2aNfVKRx52tbuw3nZWrM7rf9c1v4HtJj37AGe-Xp_c7ksqkdfZ7AxKwqWU5QV0M-cm5e3ufv5NpzCkSFdYly4aW9bKQy3RmW2aHPAbmmWoavFIweYeOnasSHKH4V0ghelwI9WUr7FANW_W-QRUTRWG8hwY3menYoz1gISYlIaZcw1TzMfGMw3yWF6JfdOFE_BkDdGb21yyQ",
  scope: "openid profile email",
  token_type:  "Bearer",
}

id_tokenやaccesstokenの詳しい仕組みはこちらが詳しいです。

もちろんjwt.ioでも、トークンはデコードできます。

スクリーンショット 2022-12-01 14.32.12.png

実装

環境変数で、メルアドやissuerを差し込んでいます。

/app/src/controllers/OAuthTokenController.ts
import { Request, Response } from "express";
import TokenCache from "../models/TokenCache";

class AuthorizeController {
  get = (req: Request, res: Response) => {
    const currentDate = new Date();
    currentDate.setDate(currentDate.getDate() + 3);

    // authorizeで保存した値を復元
    const cache = new TokenCache();
    cache.set({
      nonce: req.query["nonce"] as string,
      scope: req.query["scope"] as string,
      audience: req.query["audience"] as string || ''
    })

    const params = {
      code: "ZtVJQyXf9eOOQJi5h_xpY6CSgEcr092_3TTWT28zsy42z",
      state: req.query!["state"] as string,
    };

    switch (req.query["response_mode"]) {
      case "query":
        return AuthorizeController.createRedirectResponse(params, req, res);
      case "web_message":
        return AuthorizeController.createHTMLResponse(params, req, res);
      default:
        return res.status(400);
    }
  };

  private static createRedirectResponse = (
    params: { code: string; state: string },
    req: Request,
    res: Response
  ) => {
    const urlSearchParam = new URLSearchParams(params).toString();
    res.redirect(`${req.query!["redirect_uri"]}?${urlSearchParam.toString()}`);
  };

  private static createHTMLResponse = (
    params: { code: string; state: string },
    req: Request,
    res: Response
  ) => {
    const redirectUrl = new URL(req.query!["redirect_uri"] as string)
    const targetOrigin = redirectUrl.origin
    res.render('index', {...params, targetOrigin })
  };
}

export default AuthorizeController;

まとめ

Auth0のリクエストの仕組みがわかったのではないでしょうか?

SDKを使って認証するだけだと簡単ですが、中がどうなっているのかわかるとできることは大きいです。今回、Auth0の仕様をきちんと確認したことでかなり理解が進みました。

早くAuth0が公式のローカル用やCIで使いやすいダミーコンテナを出してくれることを祈ります。

明日は @Koichi さんです!よろしくおねがいします!

参考

下記を参考にさせていただきました。ありがとうございます。 :bow:

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
What you can do with signing up
2