You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

React应用对接Authority Server的PKCE认证无限刷新问题求助

React PKCE认证无限刷新问题排查

我正在为基础React模板对接权限服务器实现PKCE认证,虽对React不太熟悉,但已集成必要组件。启动应用后登录页正常显示,输入凭证点击登录后跳转至主页,但页面开始无限刷新,仅观察到URL参数中的code持续变化。


实现代码

1. 认证管理AuthWrapper组件

// index.js
...
<AuthWrapper>
  <App />
</AuthWrapper>
...

AuthWrapper定义:

import { useNavigate } from 'react-router-dom';
import { AuthProvider } from './AuthContext';
import PropTypes from 'prop-types';

const AuthWrapper = ({ children }) => {
  const navigate = useNavigate();
  return (
    <AuthProvider navigate={navigate}>
      {children}
    </AuthProvider>
  );
};

export default AuthWrapper;

2. AuthProvider(code_verifier暂硬编码)

import React, { createContext, useContext, useState } from "react";
import {generateCodeChallenge} from "utils/authutils";
import PropTypes from 'prop-types';
const AuthContext = createContext();
export const useAuth = () => useContext(AuthContext);
export const AuthProvider = ({ children, navigate }) => {
  const [isAuthenticated, setIsAuthenticated] = useState(false);

  const codeChallenge = generateCodeChallenge('dce35c1f-194d-48c4-bd90-6f14e9042023');

  const login = () => {
    window.location.href = "https://localhost/Identity/authorize" +
      "?client_id=web_client" +
      "&redirect_uri=http://localhost:3000/authentication/callback" +
      "&response_type=code" +
      "&scope=web_scope offline_access" +
      "&state=123" +
      `&code_challenge=${codeChallenge}` +
      "&code_challenge_method=S256";
  };

  const handleLoginCallback = () => {
    const urlParams = new URLSearchParams(window.location.search);
    const code = urlParams.get("code");
    setIsAuthenticated(true);
    navigate("/dashboard");
  };

  return (
    <AuthContext.Provider value={{ isAuthenticated, login, handleLoginCallback }}>
      {children}
    </AuthContext.Provider>
  );
};

3. 回调页面CallbackPage

import React, { useEffect } from "react";
import { useLocation } from "react-router-dom";
import { useNavigate } from 'react-router-dom';
import { exchangeAuthorizationCode } from "services/api";

const CallbackPage = () => {
  const location = useLocation();
  const navigate = useNavigate();

  useEffect(() => {
    const searchParams = new URLSearchParams(location.search);
    const authorizationCode = searchParams.get("code");

    if (authorizationCode) {
      exchangeAuthorizationCode(authorizationCode).then((accessToken) => {
        localStorage.setItem("accessToken", accessToken);
        navigate("/dashboard");
      });
    } else {
      navigate("/error");
    }
  }, [location.search, navigate]);

  return (
    <div>
      <p>Redirecting...</p>
    </div>
  );
};
export default CallbackPage;

4. API请求文件api.js

export const exchangeAuthorizationCode = async (authorizationCode, codeVerifier) => {
    const requestBody = {
      grant_type: "authorization_code",
      code: authorizationCode,
      redirect_uri: "http://localhost:3000/authentication/callback",
      code_verifier: 'dce35c1f-194d-48c4-bd90-6f14e9042023', 
      client_id: "web_client"
    };

    try {
      const response = await fetch("https://localhost/Identity/token", {
        method: "POST",
        headers: {
          "Content-Type": "application/json",
        },
        body: JSON.stringify(requestBody),
      });

      if (!response.ok) {
        throw new Error("Failed to exchange authorization code for access token");
      }

      const data = await response.json();
      return data.access_token;
    } catch (error) {
      console.error("Error exchanging authorization code:", error.message);
      throw error;
    }
  };

5. App页面重定向逻辑

...

import { useAuth } from "AuthContext";

export default function App() {

  ...
  
  const { isAuthenticated, login } = useAuth();
  const { pathname } = useLocation();

  useEffect(() => {
    if (!isAuthenticated && pathname !== "/login") {
      login(); // 重定向到权限服务器的方法
    }
  }, [isAuthenticated, pathname, login]);

  ...

  const getRoutes = (allRoutes) =>
    allRoutes.map((route) => {
      if (route.collapse) {
        return getRoutes(route.collapse);
      }

      if (route.route) {
        return <Route exact path={route.route} element={route.component} key={route.key} />;
      }

      return null;
    });

    ...

  return (
    <ThemeProvider theme={darkMode ? themeDark : theme}>
      <CssBaseline />
      <Routes>
        {getRoutes(routes)}
        <Route path="*" element={<Navigate to="/dashboard" />} />
      </Routes>
    </ThemeProvider>
  );
}

问题原因及修复方案

核心问题

  1. 认证状态未同步更新:回调页获取到accessToken后仅存入localStorage,未通知AuthProvider更新isAuthenticated状态,导致App组件检测到未认证,再次触发登录跳转,权限服务器返回新的code,形成循环。
  2. 回调页未排除在登录触发逻辑外:App组件的useEffect未排除回调页路径,在code兑换过程中仍会触发登录,重复发起认证请求。
  3. code_verifier硬编码:PKCE要求每次认证请求生成新的code_verifier,硬编码会导致重复使用失效的验证值,引发认证错误。

修复步骤

1. 同步更新认证状态

修改AuthProvider,添加状态更新方法并初始化时从localStorage读取状态:

export const AuthProvider = ({ children, navigate }) => {
  // 初始化时从localStorage读取,避免页面刷新后状态丢失
  const [isAuthenticated, setIsAuthenticated] = useState(() => !!localStorage.getItem("accessToken"));

  const codeChallenge = generateCodeChallenge('dce35c1f-194d-48c4-bd90-6f14e9042023');

  const login = () => {
    window.location.href = "https://localhost/Identity/authorize" +
      "?client_id=web_client" +
      "&redirect_uri=http://localhost:3000/authentication/callback" +
      "&response_type=code" +
      "&scope=web_scope offline_access" +
      "&state=123" +
      `&code_challenge=${codeChallenge}` +
      "&code_challenge_method=S256";
  };

  const handleLoginCallback = () => {
    const urlParams = new URLSearchParams(window.location.search);
    const code = urlParams.get("code");
    setIsAuthenticated(true);
    navigate("/dashboard");
  };

  // 添加状态更新方法
  const setAuthenticated = (status) => {
    setIsAuthenticated(status);
  };

  return (
    <AuthContext.Provider value={{ isAuthenticated, login, handleLoginCallback, setAuthenticated }}>
      {children}
    </AuthContext.Provider>
  );
};

在CallbackPage中调用状态更新方法:

import { useAuth } from "AuthContext";

const CallbackPage = () => {
  const location = useLocation();
  const navigate = useNavigate();
  const { setAuthenticated } = useAuth();

  useEffect(() => {
    const searchParams = new URLSearchParams(location.search);
    const authorizationCode = searchParams.get("code");

    if (authorizationCode) {
      exchangeAuthorizationCode(authorizationCode).then((accessToken) => {
        localStorage.setItem("accessToken", accessToken);
        setAuthenticated(true); // 更新认证状态
        navigate("/dashboard");
      }).catch(() => {
        // 处理code兑换失败,跳转回登录页
        navigate("/login");
      });
    } else {
      navigate("/error");
    }
  }, [location.search, navigate, setAuthenticated]);

  return (
    <div>
      <p>Redirecting...</p>
    </div>
  );
};

2. 排除回调页的登录触发逻辑

修改App组件的useEffect,避免在回调流程中触发登录:

useEffect(() => {
  // 排除回调页,防止code兑换过程中重复触发登录
  if (!isAuthenticated && pathname !== "/login" && pathname !== "/authentication/callback") {
    login();
  }
}, [isAuthenticated, pathname, login]);

3. 动态生成code_verifier

替换硬编码的code_verifier,每次登录时生成新值并存入sessionStorage,兑换时取出使用:

// AuthProvider中修改login方法
const login = () => {
  const codeVerifier = crypto.randomUUID(); // 生成随机code_verifier
  const codeChallenge = generateCodeChallenge(codeVerifier);
  sessionStorage.setItem("code_verifier", codeVerifier); // 存储到sessionStorage

  window.location.href = "https://localhost/Identity/authorize" +
    "?client_id=web_client" +
    "&redirect_uri=http://localhost:3000/authentication/callback" +
    "&response_type=code" +
    "&scope=web_scope offline_access" +
    "&state=123" +
    `&code_challenge=${codeChallenge}` +
    "&code_challenge_method=S256";
};

修改api.js中的兑换逻辑:

export const exchangeAuthorizationCode = async (authorizationCode) => {
  const codeVerifier = sessionStorage.getItem("code_verifier");
  if (!codeVerifier) throw new Error("Code verifier not found");

  const requestBody = {
    grant_type: "authorization_code",
    code: authorizationCode,
    redirect_uri: "http://localhost:3000/authentication/callback",
    code_verifier: codeVerifier,
    client_id: "web_client"
  };

  try {
    const response = await fetch("https://localhost/Identity/token", {
      method: "POST",
      headers: {
        "Content-Type": "application/json",
      },
      body: JSON.stringify(requestBody),
    });

    if (!response.ok) {
      throw new Error("Failed to exchange authorization code for access token");
    }

    const data = await response.json();
    sessionStorage.removeItem("code_verifier"); // 兑换后删除
    return data.access_token;
  } catch (error) {
    console.error("Error exchanging authorization code:", error.message);
    throw error;
  }
};

内容的提问来源于stack exchange,提问作者Sergio Hernández

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.06.24 14:40:56