タム
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の
インフラをそのまま活用します。
雛形アプリには元々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トークンだけでなくアクセストークンも実態は署名付き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トークンの検証は行うべきだと思います。
11
原
2024.07.31
64
原
2024.07.31
1,211
だいち
2024.05.30