0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

チャットUIのSPAにAuth0の3LO認証を組み込んでみた

0
Posted at

TL;DR

個人でAIエージェントと会話するチャットUI(SPA、React + Vite)を作っていて、まずは認証まわりだけを先に固めることにしました。Auth0のAuthorization Code + PKCE(いわゆる3LO、ユーザーの同意を伴う認可コードフロー。PKCE自体の中身は後述します)を、Auth0が公式配布しているReact向けSDK @auth0/auth0-react(ログイン状態をReactのフック経由で扱えるようにしてくれるライブラリ)で組み込み、取得したアクセストークンをチャット送信のたびにBearerトークンとして付与するところまでを実装しています。エージェント本体はまだ実装前で、この先Amazon Bedrock AgentCore Runtime上に立てる予定です。AgentCore Runtime側はAuth0のようなOIDC互換IdPをそのまま検証に使える作りになっているため、フロント側の認証部分はこのまま流用できる見込みです。

全体の流れ

登場人物が多いので、先に認可コードフロー全体を図にしておきます。AgentCore Runtimeはまだ接続前ですが、つなぐ前提で先に書いています。

1〜6がSPA側でここまでに実装した範囲、7〜9がAgentCore Runtimeをつないだ後の想定範囲です。以下、この番号を指しながら実装を見ていきます。

先に図の1と5に出てくるPKCE(Proof Key for Code Exchange)の中身にも触れておきます。SPAはサーバーサイドのアプリと違ってclient_secretを安全に保持できない「public client」なので、認可コード(図の4で返ってくる code)だけを信用すると、ブラウザ履歴やネットワークログ経由で第三者にcodeを横取りされたときにそのままアクセストークンへ交換されてしまう恐れがあります。PKCEはこれを防ぐ拡張で、ブラウザ側がリクエストのたびにランダムな秘密文字列(code_verifier)を生成し、そのSHA256ハッシュ(code_challenge)だけを図の1で /authorize に渡しておきます。そして図の5でトークンに交換するときに、元のcode_verifierをあわせて送ることで、Auth0側は「code_challengeを送った本人が今トークン交換を要求している」ことを検証できます。code_verifier自体はネットワーク上を一度も裸のまま流れないので、途中でcodeだけを盗んでもcode_verifierが分からず交換できません。@auth0/auth0-react はこのcode_verifierの生成・保持・送信を全部SDK内部でやってくれるので、アプリ側で意識する必要はありません。

Auth0Providerの設定(図の1、6)

このSDKが提供する Auth0Provider をアプリのルートで1つラップするだけで、PKCEのcode_verifier生成やトークンリフレッシュはSDK側がやってくれます。useAuth0() フックからログイン状態やユーザー情報、後述の getAccessTokenSilently() などを取り出せるようになる仕組みです。実際に書いたコードはこうです。

export function Auth0ProviderWithNavigate({ children }: PropsWithChildren) {
  const navigate = useNavigate();

  const domain = import.meta.env.VITE_AUTH0_DOMAIN;
  const clientId = import.meta.env.VITE_AUTH0_CLIENT_ID;
  const audience = import.meta.env.VITE_AUTH0_AUDIENCE;

  const onRedirectCallback = (appState?: AppState) => {
    navigate(appState?.returnTo ?? window.location.pathname, { replace: true });
  };

  return (
    <Auth0Provider
      domain={domain}
      clientId={clientId}
      authorizationParams={{
        redirect_uri: `${window.location.origin}/callback`,
        audience,
        scope: 'openid profile email',
      }}
      useRefreshTokens
      onRedirectCallback={onRedirectCallback}
    >
      {children}
    </Auth0Provider>
  );
}

ポイントは authorizationParams.audience を必ず指定することです。これを省くとAuth0は不透明トークン(opaque token)やJWEを返すことがあり、後段のAgentCore Runtime側でJWTとして署名検証できなくなります。不透明トークンというのは、JWTのように中身(claims)を自己完結的に持たず、単なるランダムな文字列としてしか読めないトークンのことです。受け取った側はトークンの中身を直接確認できず、有効性や紐づく情報を知るには認可サーバーに問い合わせる(イントロスペクション)必要があります。JWE(JSON Web Encryption)はJWTと同じ構造を持ちますが、claims部分が暗号化されていて、公開鍵での署名検証だけでは中身を読めません。AgentCore RuntimeのcustomJwtAuthorizerはJWKS(公開鍵)による署名検証だけを前提にしているため、どちらのトークンが返ってきても検証できずに弾かれてしまいます。この挙動が起きうる条件としてAuth0のドキュメントが挙げているのがDynamic Client Registration(OAuth 2.0の仕様の1つで、Auth0 Dashboardで事前に手動登録する代わりに、API経由でクライアントをその場で動的に登録できる仕組み)を使う構成で、audience未指定のままトークンをリクエストすると、JWTではなく不透明トークンやJWEが返ることがあるとされています。今回は手動でApplicationを作成しておりDynamic Client Registrationは使っていませんが、3LOでも起きうる挙動として、audienceは省略せず最初から明示しておくのが安全という判断です。

トークンの保存先(cacheLocation)はデフォルトの memory のままにしています。SDKが取得したアクセストークンをどこに保存するかの設定で、memory はJavaScriptのメモリ上に持つだけなのでタブを閉じたりページを再読み込みしたりすると消えます。localstorage にするとブラウザのlocalStorageに永続化され、ページをまたいでも都度の再認証(サイレント認証)を省けて便利ですが、XSS(クロスサイトスクリプティング、攻撃者が悪意あるスクリプトをページ内で実行させる攻撃)が一度成立すると、JavaScriptから自由に読み書きできるlocalStorage上のトークンをまるごと盗まれ、以後も居座って悪用され続けるリスクがあります。memory ならページを離れた時点で消えるので、被害はそのセッション中に限定されます。個人のデモアプリとはいえ、ここは素直にデフォルトの挙動を信頼することにしました。

かわりに useRefreshTokens を有効にしています。アクセストークンは有効期限が短い(既定1時間)ため、有効期限切れのたびにユーザーへ再ログインを求めるのは体験が良くありません。そこで有効期限の長いリフレッシュトークンを使い、裏側で新しいアクセストークンを取り直す仕組みが一般的に使われます。従来はこれを非表示のiframeで /authorize に裏側からアクセスし、既存のAuth0 SSOセッションのCookieを頼りに新しい認可コードをこっそり受け取る「hidden iframeによるサイレント認証」で実現していましたが、Safari ITPやサードパーティCookieの段階的廃止など、別ドメイン(Auth0側)のCookieをiframe越しに参照する挙動をブラウザが年々ブロックするようになっており、この方式は年々不安定になっています。useRefreshTokens: true はこれに頼らず、リフレッシュトークンローテーション(リフレッシュトークンを使うたびに古いものを無効化し、新しいリフレッシュトークンを都度発行し直す仕組み。盗まれた場合の被害を早期に検知・限定しやすくする)でサイレント認証を担わせる設定で、iframeよりも安定して動きます。

コールバックとルート保護(図の4)

Auth0からの /callback リダイレクトには code(前述の認可コード)に加えて state という値も付いてきます。state は図の1で /authorize にリクエストする際にSPA側があらかじめ生成してAuth0に渡しておくランダムな値で、コールバックで返ってきた state が送った時と同じかを照合することで、そのレスポンスが自分自身が開始したログインリクエストに対するものであることを確認します(第三者が別途生成した認可レスポンスを紛れ込ませるCSRF攻撃を防ぐ仕組みです)。役目を終えたこれらの値をURLに残しておくと、ブラウザ履歴やそのページから別サイトへ遷移した際のリファラヘッダー経由で漏れる可能性があるため、/callback は専用のルートを用意し、Auth0ProvideronRedirectCallback(SDKがコールバック処理を終えた直後に呼ぶフック)で codestate をURLから消してから元のページに戻しています。

未認証のままチャット画面に入ろうとした場合は、withAuthenticationRequired でラップしたコンポーネントがログイン画面へのリダイレクトを担当します。これはコンポーネントを描画する前にログイン状態を確認し、未ログインなら自動的に loginWithRedirect() を呼んでAuth0のログイン画面へ飛ばす高階コンポーネント(HOC)で、確認中に一瞬表示されるフォールバックUIを onRedirecting で指定できます。

export function ProtectedRoute({ children }: PropsWithChildren) {
  const Component = withAuthenticationRequired(() => <>{children}</>, {
    onRedirecting: Fallback,
  });
  return <Component />;
}

ルーティング全体は react-router-dom で、/callback と、認証必須の /(チャット画面)の2つだけの小さな構成です。

チャット送信へのトークン付与(図の5〜7)

チャットUI自体はVercel AI SDKを使っています。コアの ai パッケージとReact向けの @ai-sdk/react が提供する useChat フックが、入力フォームの状態管理、送信、ストリーミングで少しずつ届く応答をリアルタイムに再描画する処理、メッセージ履歴(messages 配列)の管理をまとめて面倒を見てくれます。バックエンド側の実装がVercel製である必要はなく、DefaultChatTransport に任意のエンドポイントURLと fetch 実装を渡せる作りになっているので、今回のようにAgentCore Runtime(や他の任意のHTTPエンドポイント)にも同じ書き方でつなげます。エージェントのエンドポイントを呼ぶ際は、リクエストごとに getAccessTokenSilently() でアクセストークンを取り直し、Authorization ヘッダーに載せるカスタム fetch をこの DefaultChatTransport に渡しています。

export function useAgentTransport() {
  const { getAccessTokenSilently } = useAuth0();

  return new DefaultChatTransport({
    api: import.meta.env.VITE_AGENT_ENDPOINT,
    fetch: async (input, init) => {
      const token = await getAccessTokenSilently();
      const headers = new Headers(init?.headers);
      headers.set('Authorization', `Bearer ${token}`);
      return fetch(input, { ...init, headers });
    },
  });
}

固定のヘッダーとして持たせずリクエストのたびに getAccessTokenSilently() を呼んでいるのは、アクセストークンの有効期限がデフォルト1時間と短いためです。SDK側がトークンをメモリにキャッシュしていて有効期限内なら追加のネットワーク往復は発生しないので、呼び出しコストを気にせず都度解決する書き方にしています。現時点ではエージェント側が未実装のため VITE_AGENT_ENDPOINT は空のプレースホルダーで、送信ボタンを押してもUI上でメッセージは表示されますが応答は返ってきません。認証とホスティングの配線だけを先に確認した形です。

Callback URLとaudience認可でハマった点

Auth0 Dashboard側の設定で2箇所つまずきました。

1つ目はCallback URLの登録です。Allowed Callback URLs にはCloudFrontのドメインだけでなく /callback までパス込みで完全一致させる必要があり、パスを付け忘れて Callback URL mismatch になりました。

2つ目はもう少し分かりにくいものでした。専用のAPI(Resource Server、audience)を新規作成してCallback URLも正しく設定したのに、ログインしようとすると次のエラーになりました。

Client "..." is not authorized to access resource server "https://<audience識別子>"

3LO(Authorization Code)フローだから、APIを作ってしまえばあとは勝手に使えるだろうと思い込んでいたのが原因でした。実際にはAPI作成だけでは足りず、Auth0 Dashboardの対象APIの「アプリケーションアクセス」タブ(旧UIでは「Machine to Machine Applications」に相当する場所)で、対象Applicationの「ユーザー委任アクセス」(User delegated access)を明示的にONにする必要があります。このタブの名前と、隣にある「クライアントアクセス」(M2M / Client Credentials用のトグル)がまぎらわしく、M2M専用の設定だろうと思って見落としがちでした。3LOであってもここを有効化しないとログイン自体がエラーで止まります。

AgentCore Runtime側の検証(図の7〜9)

ここから先はまだ接続していない部分ですが、エージェント側(AgentCore Runtime)のCDKスタックにはすでにJWT検証の設定が入っています。AWS::BedrockAgentCore::RuntimeauthorizerConfigurationcustomJwtAuthorizer を渡す形です。

authorizerConfiguration: {
  customJwtAuthorizer: {
    discoveryUrl: auth0DiscoveryUrl,
    allowedAudience: [protocolEntry.auth0Audience ?? auth0Audience],
    ...(scopes !== undefined && { allowedScopes: scopes }),
  },
},

OIDC discoveryエンドポイントというのは、IdP(この場合Auth0)が /.well-known/openid-configuration という決まったパスで公開しているJSONで、認可エンドポイントやトークンエンドポイント、そして次に出てくるJWKSの取得先URLなど、そのIdPとやり取りするのに必要な情報が一通りまとまっています。discoveryUrl にこの1つのURLを渡しておくだけで、AgentCore Runtime側は芋づる式にJWKSのURLまでたどり着けます。

JWKS(JSON Web Key Set)は公開鍵を複数まとめたJSON形式の鍵セットです。Auth0が発行するJWTは秘密鍵で署名されていて、それと対になる公開鍵がJWKSに載っています。JWTはヘッダー・ペイロード・署名の3つを . でつないだ文字列で、検証する側(ここではAgentCore Runtime)はJWKSから対応する公開鍵を取り出し、その鍵でヘッダーとペイロードに対する署名を検証します。これにより、確かにそのAuth0テナントが発行したトークンであること、発行後にペイロードが改ざんされていないことを確認できます。署名検証を通ったうえで、allowedAudience に指定した値と実際のJWTの aud クレーム(図の6でSPA側が指定したのと同じaudience)が一致しているかも別途チェックされ、ここまで通ったリクエストだけがエージェントの処理まで届きます。

SPA側の実装はそのままで、あとはAgentCore Runtime側に discoveryUrlallowedAudience の2つの値を設定するだけでつながる見込みです。この署名検証の仕組み自体はOIDCとJWKSという標準化されたプロトコルの組み合わせで、Auth0固有の実装に依存していません。そのためAgentCore RuntimeのJWT検証はIdPを問わず(同じ標準に対応してさえいれば)動く作りになっており、Auth0を使い続けるかCognitoなど別のIdPに切り替えるかは、この先の要件次第で選べる状態になっています。

次にやること

エージェント本体(AgentCore Runtime上のバックエンド、MCPサーバ経由でツールを1つ呼べる程度の最小構成を予定)を実装したら、VITE_AGENT_ENDPOINT を実際のエンドポイントに差し替えて図の7〜9を通す予定です。あわせて、AgentCore RuntimeにはAgentCore Identityという、outbound(エージェントから外部サービスへのアクセス)のトークン管理やagent同士のID管理をまとめて面倒見てくれる仕組みもあるようで、今回のようにAuth0だけで完結する構成から一歩進んで、エージェントが外部SaaSにユーザーの代わりにアクセスするようなケースが出てきたら候補として検討したいと思っています。

参考リンク

0
0
0

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
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?