Auth0環境を構築して自作のAPIと連携してみた

タム

2024.04.15

995

こんにちは。タムです。

前回まで、Googleでログインの話をやってきました。

次なるステップとして、Google以外のIdPとの連携を試したいところです。

基本的な仕組みはOIDCがベースになっているはずなので、

それぞれに個別の実装をしなくてもディスカバリドキュメントなど最小限の

IdP固有の設定を管理すれば同じ仕組みが使えることが想像できます。

ただそれでも、実際に一から実装するとなるとなかなか大変そうな気がします。

というわけで今回は、複数のIdP連携をAuth0を使うことで簡単に実現してみました。

Auth0では最初にアプリのプラットフォームと言語を選べますが、

今回はSSRのNextJSを選びました。

Getting Startedで最終的にNextJSプロジェクトの雛形がダウンロードできるのでそれをベースに実装してます。

成果物

APIの疎通を確認したいだけなので今回はレスポンスをそのまま貼ってます。

設計

インフラ

図にするまでもないほど単純ですがインフラ構成図は以下のとおりです。

NextJSアプリはAmplifyでホスティングしました。

下のAPI Gateway + Lambdaは自作のAPIで、前回までで使っていたMicropost APIの

インフラをそのまま活用します。

API連携の実装

概要

雛形アプリには元々External APIとしてread:showsというスコープが必要なAPIが定義されています。

今回は追加でread:micropostsというスコープを要求する自作のAPIを認可して呼んでみようと思います。


IDトークンのaudはクライアントアプリケーション(今回であればNextJSアプリ)ですが

アクセストークンのaudはAUTH0_AUDIENCE環境変数で設定したものになります。

注意点としてAuth0のアクセストークンのaudは一つしか取れないようです(userinfoエンドポイント用のものを除いて)。

複数のAPIを使いたい場合はどうすればいいかというと、audは共通のものを使い、scopeで区切ればいいみたいです。

こちらの記事を参考にさせていただきました)

今回は、my-custom-apiというaud(API)を登録し要求するスコープとしてread:shows read:micropostsを定義しました。

フロントエンド

diff --git a/app/api/microposts/route.js b/app/api/microposts/route.js
new file mode 100644
index 0000000..b16ab50
--- /dev/null
+++ b/app/api/microposts/route.js
@@ -0,0 +1,23 @@
+import { getAccessToken, withApiAuthRequired } from '@auth0/nextjs-auth0';
+import { NextResponse } from 'next/server';
+
+export const GET = withApiAuthRequired(async function shows(req) {
+  try {
+    const res = new NextResponse();
+    const { accessToken } = await getAccessToken(req, res, {
+      scopes: ['read:microposts']
+    });
+    const apiPort = process.env.API_PORT || 8000;
+    const apiHost = process.env.API_HOST;
+    const response = await fetch(`${apiHost}:${apiPort}/auth0/microposts`, {
+      headers: {
+        Authorization: `Bearer ${accessToken}`
+      }
+    });
+    const shows = await response.json();
+
+    return NextResponse.json(shows, res);
+  } catch (error) {
+    return NextResponse.json({ error: error.message }, { status: error.status || 500 });
+  }
+});
diff --git a/app/microposts/page.jsx b/app/microposts/page.jsx
new file mode 100644
index 0000000..edf0e43
--- /dev/null
+++ b/app/microposts/page.jsx
@@ -0,0 +1,75 @@
+'use client';
+
+import React, { useState } from 'react';
+import { Button } from 'reactstrap';
+import { withPageAuthRequired } from '@auth0/nextjs-auth0/client';
+
+import Loading from '../../components/Loading';
+import ErrorMessage from '../../components/ErrorMessage';
+import Highlight from '../../components/Highlight';
+
+function External() {
+  const [state, setState] = useState({ isLoading: false, response: undefined, error: undefined });
+
+  const callApi = async () => {
+    setState(previous => ({ ...previous, isLoading: true }));
+
+    try {
+      const response = await fetch('/api/microposts');
+      const data = await response.json();
+
+      setState(previous => ({ ...previous, response: data, error: undefined }));
+    } catch (error) {
+      setState(previous => ({ ...previous, response: undefined, error }));
+    } finally {
+      setState(previous => ({ ...previous, isLoading: false }));
+    }
+  };
+
+  const handle = (event, fn) => {
+    event.preventDefault();
+    fn();
+  };
+
+  const { isLoading, response, error } = state;
+
+  return (
+    <>
+      <div className="mb-5" data-testid="external">
+        <h1 data-testid="external-title">Microposts</h1>
+        <div data-testid="external-text">
+          <p className="lead">Ping an external API by clicking the button below</p>
+          <p>
+            This will call a local API on port 3001 that would have been started if you run <code>npm run dev</code>.
+          </p>
+          <p>
+            An access token is sent as part of the request's <code>Authorization</code> header and the API will validate
+            it using the API's audience value. The audience is the identifier of the API that you want to call (see{' '}
+            <a href="https://auth0.com/docs/get-started/dashboard/tenant-settings#api-authorization-settings">
+              API Authorization Settings
+            </a>{' '}
+            for more info).
+          </p>
+        </div>
+        <Button color="primary" className="mt-5" onClick={e => handle(e, callApi)} data-testid="external-action">
+          Ping API
+        </Button>
+      </div>
+      <div className="result-block-container">
+        {isLoading && <Loading />}
+        {(error || response) && (
+          <div className="result-block" data-testid="external-result">
+            <h6 className="muted">Result</h6>
+            {error && <ErrorMessage>{error.message}</ErrorMessage>}
+            {response && <Highlight>{JSON.stringify(response, null, 2)}</Highlight>}
+          </div>
+        )}
+      </div>
+    </>
+  );
+}
+
+export default withPageAuthRequired(External, {
+  onRedirecting: () => <Loading />,
+  onError: error => <ErrorMessage>{error.message}</ErrorMessage>
+});
diff --git a/components/NavBar.jsx b/components/NavBar.jsx
index bf4eea4..c393d66 100644
--- a/components/NavBar.jsx
+++ b/components/NavBar.jsx
@@ -54,6 +54,11 @@ const NavBar = () => {
                       External API
                     </PageLink>
                   </NavItem>
+                  <NavItem>
+                    <PageLink href="/microposts" className="nav-link" testId="navbar-external">
+                      Microposts
+                    </PageLink>
+                  </NavItem>
                 </>
               )}
             </Nav>


中身はほぼテンプレにあったExternal APIのコピペです。

バックエンド

diff --git a/app.py b/app.py
index df4073e..c6791ef 100644
--- a/app.py
+++ b/app.py
@@ -6,6 +6,7 @@ import urllib
 from datetime import datetime, timedelta, timezone
 from chalicelib.models import Microposts
 from chalicelib.utils import login
+import jwt
 
 
 app = Chalice(app_name="XXXXXXXXXXXXXXXXXXX")
@@ -16,6 +17,7 @@ ssm = boto3.client("ssm")
 CLIENT_ID = ssm.get_parameter(Name="/google_oauth/client_id")["Parameter"]["Value"]
 CLIENT_SECRET = ssm.get_parameter(Name="/google_oauth/client_secret")["Parameter"]["Value"]
 REDIRECT_URI = ssm.get_parameter(Name="/google_oauth/redirect_uri")["Parameter"]["Value"]
+AUTH0_CLIENT_ID = ssm.get_parameter(Name="/auth0/client_id")["Parameter"]["Value"]
 
 
 @app.route("/get_token", methods=["POST"], cors=CORSConfig(allow_origin="https://XXXXXXXXXXXXXXXXXXXXXXXXXXXXX"))
@@ -112,3 +114,33 @@ def _parse_schedule(schedule_str):
     summary = " ".join(parts[5:])
 
     return start, end, summary
+
+
+@app.route("/auth0/microposts", methods=["GET"])
+def get_micropost_for_auth0():
+    if "Authorization" not in app.current_request.headers:
+        return Response(body=None, status_code=401)
+
+    access_token = app.current_request.headers["Authorization"].removeprefix("Bearer ")
+
+    jwks_client = jwt.PyJWKClient("https://dev-XXXXXXXXXXXXXXXXXXXXX.us.auth0.com/.well-known/jwks.json")
+    try:
+        signing_key = jwks_client.get_signing_key_from_jwt(access_token)
+        payload = jwt.decode(
+            access_token,
+            signing_key.key,
+            algorithms=["RS256"],
+            audience=AUTH0_CLIENT_ID,
+        )
+    except Exception as e:
+        app.log.error(e)
+        return Response(body=None, status_code=401)
+
+    # scopeの検証
+    if "read:microposts" not in payload["scope"].split():
+        app.log.error("insufficient scope")
+        return Response(body=None, status_code=401)
+
+    posts = Microposts.query(payload.get("sub"))
+    res = [post.to_simple_dict() for post in posts]
+    return Response(body=res, status_code=200)


Auth0のアクセストークンとIDトークン

Auth0においてはIDトークンだけでなくアクセストークンも実態は署名付きJWTです。

実装していてそれぞれのpayloadを参照したいことが結構あると思ったので以下に記載しておきます。


IDトークンのpayload

{
  "nickname": "XXXXXXX",
  "name": "XXXXXXXXXXXXXXX",
  "picture": "https://s.gravatar.com/avatar/XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX.png",
  "updated_at": "2024-04-18T14:31:52.952Z",
  "iss": "https://dev-XXXXXXXXXXXXXXXXXXXXXXXXXXX.us.auth0.com/",
  "aud": "XXXXXXXXXXXXXXXXXXXXXXXX",
  "iat": 1713450713,
  "exp": 1713486713,
  "sub": "auth0|XXXXXXXXXXXXXXXXXXXXXXXXXX",
  "sid": "XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX",
  "nonce": "XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX"
}


アクセストークンのpayload

{
  "iss": "https://dev-XXXXXXXXXXXXXXXXXXXXXXXX.us.auth0.com/",
  "sub": "auth0|XXXXXXXXXXXXXXXXXXXXXXXXXXXXXx",
  "aud": [
    "my-custom-api",
    "https://dev-XXXXXXXXXXXXXXXXXXXXXX.us.auth0.com/userinfo"
  ],
  "iat": 1713452513,
  "exp": 1713538913,
  "scope": "openid profile read:shows read:microposts",
  "azp": "XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX"
}

さいごに

今回はAuth0環境の構築から自作のAPIを投げてみるまで試してみました。


迷ったのはAPIでユーザを検証するときにIDトークンを使うかアクセストークンを使うかです。

APIのクライアントが確実に限定される場合はIDトークンのほうが

ユーザが認証されていることが保証されるのでセキュリティ的なことを考えてもいいと思いますが、

アクセストークンを使うのでも使い方としては正しいと思います。

(むしろ投稿アプリの使用をユーザが認可したという意味になるのでアクセストークンの方が

本来の目的にあってそうな気がします)

ただその考え方を推し進めると全てのバックエンドとのやり取りはアクセストークンを使えばいいとなって

逆にIDトークンいらなくない?とちょっと思いましたが、

見ての通りIDトークンにはユーザ名やアバターなどアプリの見た目をユーザのためにカスタマイズするのに

必要ですし(userinfoエンドポイントにアクセスすればいいじゃんという話もありますが・・・)、

アクセストークンでは認証の証明にはならないためアプリ側でIDトークンの検証は行うべきだと思います。

この記事をシェアする