最近フロントエンド側にも手を出している @nyasba です。
Reactでクライアント側のOpenIDConnectでの認証機能を実装することがあったので知見をまとめます。Reactの場合、ライブラリの組み合わせ部分まで含めてまとまった情報がなく少し戸惑いました。
やったこと
クライアント側でOpenIDConnectの認可コードフローでの認証機能を実現し、
取得したアクセストークンを用いてGraphQLリクエストでの認証を行えるようにすること。
※GraphQLサーバ側の実装は含みません。
シーケンス
mermaid形式の元ファイル
sequenceDiagram
  participant u as ユーザ
  participant c as クライアントアプリ<br>(React)
  participant a as 認証サーバ<br>(Keycloak)
  participant s as GraphQLサーバ<br>(今回は対象外)
  u ->>+ c: 認証が必要な画面にアクセス
  c -->>- u: 認証画面にリダイレクト
  u ->>+ a: 認証画面表示
  a -->>- u: 認証完了、redirect_uriにリダイレクト
  u ->>+ c: /callback?認可コード
  c ->>+ a: 認可エンドポイント
  a -->>- c: アクセストークンを取得
  c ->>+ s: 認証ヘッダーを付与してGraphQLエンドポイントを呼び出す
  s ->> s: 認証情報の検証
  s ->>+ a: ユーザエンドポイント
  a ->>- s: ユーザ情報
  s ->> s: ユーザ情報をもとに処理
  s ->>- c: レスポンス
  c ->>- u: 画面表示
実施環境
- React v17
- React Router v6
- react-oidc-context (OIDCクライアント)
- urql (GraphQLクライアント)
- @urql/exchange-auth (urqlの認証機能拡張)
- keycloak(認証サーバ)
認証サーバ側の設定(Keycloak)
- dockerなどでkeycloakを立ち上げる
- Realmを登録
- Clientを登録
- accesTypeはpublicとする
- webOrigin・redirect_uriの設定を行う(webOriginはCORSのための設定)
 
- ログイン用のユーザを作成する
クライアントで実装した内容まとめ
以下のように段階的に実装していったので、その順に解説します。
- 認証が必要なルーティングの場合に認証画面にリダイレクトするようにする
- 認証後のコールバックを受け取る
- アクセストークンを取得する
- GraphQLの通信時にAuthorizationヘッダーにセットする
1. 認証が必要なルーティングの場合に認証画面にリダイレクトするようにする
まずはRouterに AuthProviderを設定します。react-oidc-contextの公式ドキュメントにある通り、 oidcCondigで、認証サーバに関する設定を行います。 認証が必要なページへのルーティングについては RouteAuthGuardを使ってコンポーネントをラップすることで未認証時のリダイレクトを実現しています。
    <Router>
      <AuthProvider {...oidcConfig}>
        <Provider value={graphqlClient}>
          <Routes>
           <Route path="/auth" element={ <RouteAuthGuard component={<AuthPage />} /> } />
           <Route path="/noauth" element={<NoAuthPage />} />
           <Route path="/callback" element={<AuthCallback />} />
         </Routes>
        </Provider>
      </AuthProvider>
    </Router>
const oidcConfig = {
  authority: 'https://{認証サーバのホスト}/auth/realms/{Realm}',
  client_id: '{ClientId}',
  redirect_uri: `{クライアントアプリのホスト}/callback`,
};
認証が必要なページに被せるガードコンポーネントです。未認証の場合、 auth.signinRedirect()でログイン画面に飛ばしています。
実際のリダイレクトURLは下記の通り。何もしなくてもPKCEにも対応できているようです
https://{認証サーバのドメイン}/auth/realms/{Realm}/protocol/openid-connect/auth
  ?client_id={client_id}
  &redirect_uri={redirect_uri}
  &response_type=code
  &scope=openid
  &state=bd7035c6e9854471940797510552b5f5
  &code_challenge=ahn3Nr1Xj7rDZMZ4fJ10FV-OIYc5NeY_4HhanuOlFJY
  &code_challenge_method=S256
  &response_mode=query
import { ReactNode, VFC } from 'react';
import { useAuth } from 'react-oidc-context';
import { useLocation } from 'react-router-dom';
export const PATH_LOCAL_STORAGE_KEY = 'path';
type Props = {
  component: ReactNode;
};
/**
 * 認証ありのRouteガード
 */
export const RouteAuthGuard: VFC<Props> = ({ component }) => {
  // 認証情報にアクセスするためのhook
  const auth = useAuth();
  const location = useLocation();
  if (auth.isLoading) {
    return <Loading />; // ローディングコンポーネント
  }
  if (auth.error) {
    throw new Error('unauthorized'); // 要件に応じて実装を見直すこと
  }
  // 認証されていなかったらリダイレクトさせる前にアクセスされたパスを残しておく
  if (!auth.isAuthenticated) {
    localStorage.setItem(PATH_LOCAL_STORAGE_KEY, location.pathname);
    void auth.signinRedirect();
  }
  return <>{component}</>;
};
export default RouteAuthGuard;
2. 認証後のコールバックを受け取る
oidcCondigで設定した通り、 認証が完了すると/callbackにリダイレクトして戻ってきます。 あらかじめ Routerに定義していた通り、AuthCallbackコンポーネントが呼ばれ、元々アクセスしようとしていた画面が表示されることになります。
ここでauth.isLoadingの判定がない場合、認可コードからアクセストークンを取得する処理などの間に再度ルーティング判定が行われ、無限リダイレクトが発生することになりますのでご注意ください
import { VFC } from 'react';
import { Navigate } from 'react-router-dom';
import { PATH_LOCAL_STORAGE_KEY } from 'auth/RouteAuthGuard';
import { useAuth } from 'react-oidc-context';
/**
 * 認証後のCallbackエンドポイント
 */
export const AuthCallback: VFC = () => {
  const auth = useAuth();
  if (auth.isLoading) {
    return <Loading />; // ローディングコンポーネント
  }
  // ログイン前にアクセスしようとしていたパスがあれば取得してリダイレクト
  const redirectLocation = localStorage.getItem(PATH_LOCAL_STORAGE_KEY);
  localStorage.removeItem(PATH_LOCAL_STORAGE_KEY);
  return <Navigate to={redirectLocation ?? '/'} replace />;
};
export default AuthCallback;
3. アクセストークンを取得する
これで認証が完了した状態になりますので、アクセストークンをSessionStorageから取得できるようになります。アクセストークンとその有効期限をurqlで利用するので取得できるようにしておきます。
なお、アクセストークン自体は有効期限が短かったとしても、ライブラリ側で自動的にリフレッシュトークンを用いたアクセストークンの再取得が行われるため、リフレッシュ処理を意識する必要はありませんでした。
export type AuthToken = {
  token: string;
  expiredAt: number | undefined;
};
export const getAuthToken = (): AuthToken | null => {
  const oidcData = sessionStorage.getItem(
    `oidc.user:${oidcConfig.authority}:${oidcConfig.client_id}`,
  );
  if (!oidcData) {
    return null;
  }
  const authUser = User.fromStorageString(oidcData);
  return !authUser
    ? null
    : {
        token: authUser.access_token, // アクセストークン
        expiredAt: authUser.expires_at, // 有効期限
      };
};
4. GraphQLの通信時にAuthorizationヘッダーにセットする
次は、urqlの公式ドキュメントに従い、GraphQL通信時の認証ヘッダーの追加を行います。基本的には公式に従って作っています。
ここでは、fetchExchangeより前にauthExchangeの設定をしておく必要があります (
(...defaultExchangesを使っている場合も、順序の制御が必要となるため、この書き方に変える必要があります)
/**
 * GraphQL Client設定
 */
const graphqlClient = createClient({
  url: '/graphql',
  exchanges: [
    dedupExchange,
    cacheExchange,
    authExchange(authConfig), // fetchExchangeの前に設定する必要がある
    fetchExchange
  ],
});
authConfigの中身はこちら。
import { AuthConfig } from '@urql/exchange-auth';
import { makeOperation } from 'urql';
import { getAuthToken, AuthToken } from 'auth/AuthToken';
import { getUnixTime } from 'date-fns';
export const authConfig: AuthConfig<AuthToken> = {
  // 認証ヘッダーにアクセストークンを追加 
  addAuthToOperation: ({ authState, operation }) => {
    if (!authState || !authState.token) {
      return operation;
    }
    const fetchOptions =
      typeof operation.context.fetchOptions === 'function'
        ? operation.context.fetchOptions()
        : operation.context.fetchOptions || {};
    return makeOperation(operation.kind, operation, {
      ...operation.context,
      fetchOptions: {
        ...fetchOptions,
        headers: {
          ...fetchOptions.headers,
          Authorization: `Bearer ${authState.token}`,
        },
      },
    });
  },
  // 認証が間も無く切れるかどうかをauthTokenの有効期限から判定する
  willAuthError: ({ authState }) => {
    if (!authState) {
      return true;
    }
    if (
      authState.expiredAt &&
      authState.expiredAt < getUnixTime(new Date())
    ) {
      return true;
    }
    return false;
  },
  // 認証に失敗したかどうかをGraphQLのレスポンスから判定する
  didAuthError: ({ error }) =>
    error.graphQLErrors.some((e) => e.extensions?.code === 'FORBIDDEN'),
  // 認証情報を取得する
  getAuth: ({ authState }): Promise<AuthToken | null> => {
    if (!authState) {
      return new Promise((resolve) => resolve(getAuthToken()));
    }
    return new Promise((resolve) => resolve(null));
  },
};
export default authConfig;
これでurqlによるGraphQL通信でAuthorizationヘッダーにアクセストークンをセットすることができました。
おわり。

