使用React・GraphQL(urql)进行OpenID Connect认证

最近,我也开始尝试着涉足前端开发,@nyasba。

因为我曾经在React中实现客户端的OpenIDConnect认证功能,所以我将经验总结如下。在React中,包括库的组合部分在内的相关信息很少,让我有点困惑。

做过的事情

使客户端能够通过OpenIDConnect的授权代码流实现认证功能,并利用获取到的访问令牌来进行GraphQL请求的认证。

※本文中不包含GraphQL服务器端的实现。

序列

mermaid-diagram-20220223105724.png
人魚的原始文件sequence.mmd
序列图
参与者u作为用户
参与者c作为客户端应用(React)
参与者a作为认证服务器(Keycloak)
参与者s作为GraphQL服务器(本次排除在外)
用户u ->>+ 客户端c:访问需要认证的页面
客户端c –>>- 用户u:重定向至认证页面
用户u ->>+ 认证服务器a:显示认证页面
认证服务器a –>>- 用户u:认证完成,重定向至redirect_uri
用户u ->>+ 客户端c:/callback?授权码
客户端c ->>+ 认证服务器a:授权终结点
认证服务器a –>>- 客户端c:获取访问令牌
客户端c ->>+ GraphQL服务器s:附加认证头并调用GraphQL终结点
GraphQL服务器s ->> s:验证认证信息
GraphQL服务器s ->>+ 认证服务器a:用户终结点
认证服务器a ->>- GraphQL服务器s:用户信息
GraphQL服务器s ->> s:根据用户信息进行处理
GraphQL服务器s ->>- 客户端c:响应
客户端c ->>- 用户u:页面显示

执行环境 (shí shī

    • 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のための設定)

ログイン用のユーザを作成する

整理了在客户端实施的内容。

因为我们按照以下的步骤逐步实施,所以我会按照这个顺序进行解释。

    1. 当需要认证的路由时,将重定向到认证页面

 

    1. 接收认证后的回调

 

    1. 获取访问令牌

 

    在GraphQL通信时设置Authorization头部

如果路由需要认证,就将其重定向到认证页面。

首先,在路由器中设置 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。根据之前在路由中定义的情况,会调用 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, // 有効期限
      };
};

在与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標頭中設置訪問令牌。

结束了。

bannerAds