この記事は 弁護士ドットコム 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に沿って認証されます。
エンドポイントの仕様は下記にまとまっています。
リクエストの流れ
- ユーザーが
ログイン
をクリックします - Auth0 の SDK は、ユーザーを Auth0 Authorization Server
/authorize
にリダイレクトします - Auth0 承認サーバーは、ユーザーをログインおよび承認プロンプトにリダイレクトします。
- ユーザーは、構成されたログイン オプションの 1 つを使用して認証します。
- Auth0 認可サーバーは、ユーザーを認可付きのアプリケーションにリダイレクトします。
- Auth0 の SDK はこれ codeを Auth0 Authorization Server に送信します
/oauth/token
アプリケーションのクライアント ID とクライアント シークレットと共に。 - Auth0 認可サーバーは、コード、クライアント ID、およびクライアント シークレットを検証します。
- Auth0 認可サーバーは、ID トークンとアクセス トークンで応答します。
- アプリケーションは、アクセス トークンを使用して API を呼び出し、ユーザーに関する情報にアクセスできます。
- API は要求されたデータで応答します。
- 認証するのが
/authorize
エンドポイント、トークンの取得が/oauth/token
エンドポイントです。 - Auth0へのリクエストはiframeから行われるものと、iframe外通常のJSから実行されるものの2種類があります。
RS256
Auth0 では、JSON Web トークン (JWT) に署名するために、RS256
と HS256
の 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を使っています。
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 が含まれます。
下記がモックサーバーのリクエストの結果です。
{
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で検証する流れは下記のようになります。
- このエンドポイントにリクエストして、JWKS(鍵)を取得します。
- リクエストの
Authorization
ヘッダーから JWT を抽出します。 - JWT をデコードし、ヘッダーから
kid
プロパティを取得します。 - 1で取得したJWKSから、一致する
kid
プロパティを持つ署名検証キーを見つけます。 -
x5c
プロパティを使用して、JWT 署名の検証に使用される証明書を作成します。 - JWT に想定されるオーディエンス、発行者、有効期限などが含まれていることを確認します。
この後実装するトークンの署名の時のkid
とJWTSのkid
を合わせる必要があります。また、公開鍵のデータは x5c
に含まれます。
実装
expressの実行前に静的ファイルを/app/public/.well-known/jwks.json
に吐き出します。
実装する時には、動作に影響のないe, nなどは適当にダミーを入れてます。
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;
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でサイレント認証を実現する仕組みです。
サイレント認証する場合、下記のような流れになります。
- ユーザに見えない iframe を生成する
- iframe から /authorize へリクエストしてHTMLのレスポンスを受け取る
- レスポンスの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を返します。
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;
<!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_token
とid_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でも、トークンはデコードできます。
実装
環境変数で、メルアドやissuerを差し込んでいます。
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 さんです!よろしくおねがいします!
参考
下記を参考にさせていただきました。ありがとうございます。