基于 Fastify 与 Relay 构建健壮的 JWT 无感知刷新机制


在构建依赖短时效 JWT (JSON Web Token) 的单页应用时,一个无法回避的工程问题是如何处理 Access Token 的过期。当用户正在与应用交互,多个并发数据请求被发出时,Token 恰好失效。这会导致一连串的请求失败,用户界面出现多个错误提示,甚至直接被强制登出,体验极差。一个常见的错误是为每个失败的请求都单独触发一次 Token 刷新,这会瞬间向认证服务器发起洪水般的刷新请求,造成资源浪费和潜在的竞态条件。

这里的核心挑战在于,现代前端框架(如 Relay)的数据请求模型是并行的。我们需要的不是一个简单的请求重试逻辑,而是一个具备状态管理、请求排队和锁机制的网络层,它必须能将多个因 Token 失效而失败的并发请求“合并”为一次 Token 刷新,并在刷新成功后,无感知地让所有等待的请求继续执行。

本文将复盘一次从零开始构建这样一套健壮的无感知刷新机制的完整过程。技术栈选用 Fastify 作为后端 API 服务,它以高性能和低开销著称;Mercurius 插件提供 GraphQL 支持;前端则采用 Relay,一个为性能而生的声明式 GraphQL 客户端。我们将深度剖析从后端 Token 签发、到前端网络层设计、再到最终实现的所有关键代码和决策。

第一步:定义后端认证契约

一切的起点是后端。我们需要一个清晰、安全的认证流程。在真实项目中,我们采用 Access Token + Refresh Token 的组合策略。

  • Access Token: 生命周期极短(例如15分钟),用于访问受保护资源。它随每个 API 请求发送。
  • Refresh Token: 生命周期较长(例如7天),仅用于获取新的 Access Token。它被安全地存储,通常在 HttpOnly cookie 中,以防止 XSS 攻击。

基于 Fastify,我们可以借助 fastify-jwtfastify-cookie 插件快速搭建这套体系。

// server.js
import Fastify from 'fastify';
import fastifyJwt from '@fastify/jwt';
import fastifyCookie from '@fastify/cookie';
import mercurius from 'mercurius';
import { schema } from './schema.js'; // GraphQL Schema

const app = Fastify({ logger: true });

// 从环境变量加载密钥,这是生产实践的基础
const ACCESS_TOKEN_SECRET = process.env.ACCESS_TOKEN_SECRET || 'a-very-secret-key-for-access-token';
const REFRESH_TOKEN_SECRET = process.env.REFRESH_TOKEN_SECRET || 'a-super-secret-key-for-refresh-token';

app.register(fastifyCookie);

app.register(fastifyJwt, {
  secret: ACCESS_TOKEN_SECRET,
  // 解码并验证请求头中的 'authorization' bearer token
  // 这是保护 GraphQL 端点的主要方式
  namespace: 'access',
  jwtDecode: { complete: true },
});

// 我们需要一个独立的 JWT 实例来处理 Refresh Token
// 它从 cookie 中读取,而不是 'authorization' 头
app.register(fastifyJwt, {
  secret: REFRESH_TOKEN_SECRET,
  namespace: 'refresh',
  cookie: {
    cookieName: 'jid',
    signed: false, // 在生产中建议开启签名
  },
  jwtDecode: { complete: true },
});

// 模拟用户数据库
const users = {
  '1': { id: '1', name: 'Alice', password: 'password123' }
};

// 认证装饰器,用于 GraphQL resolver
app.decorate('authenticate', async function (request, reply) {
  try {
    await request.access.jwtVerify();
  } catch (err) {
    // 这里的错误消息和 code 需要与前端约定好
    reply.code(401).send({
      errors: [{
        message: 'Unauthorized: Access Token is invalid or expired.',
        extensions: { code: 'UNAUTHENTICATED' }
      }]
    });
  }
});

// 注册 GraphQL 服务
app.register(mercurius, {
  schema,
  graphiql: true,
  context: (request, reply) => ({
    // 将认证方法注入到 GraphQL 上下文,方便 resolver 调用
    authenticate: app.authenticate.bind(app, request, reply),
    user: request.access.user,
  }),
});

// === 认证路由 ===

// 1. 登录路由
app.post('/login', (request, reply) => {
  const { username, password } = request.body;
  const user = Object.values(users).find(u => u.name === username);

  if (!user || user.password !== password) {
    return reply.code(401).send({ error: 'Invalid credentials' });
  }

  const accessToken = app.access.jwtSign({ userId: user.id }, { expiresIn: '15m' });
  const refreshToken = app.refresh.jwtSign({ userId: user.id }, { expiresIn: '7d' });
  
  // Refresh Token 存储在 HttpOnly Cookie 中
  reply.setCookie('jid', refreshToken, {
    httpOnly: true,
    path: '/refresh_token',
    // secure: true, // 生产环境必须
    // sameSite: 'strict', // 生产环境建议
  }).send({ accessToken });
});

// 2. Token 刷新路由
app.post('/refresh_token', async (request, reply) => {
  try {
    // fastify-jwt 已经从 cookie 中验证了 refresh token
    const { payload } = await request.refresh.jwtVerify();
    
    // 验证通过,签发新的 access token
    const accessToken = app.access.jwtSign({ userId: payload.userId }, { expiresIn: '15m' });
    return { accessToken };
  } catch (err) {
    app.log.warn('Refresh token failed verification', err);
    // 清除无效的 cookie
    reply.clearCookie('jid', { path: '/refresh_token' });
    return reply.code(401).send({ error: 'Refresh token invalid or expired.' });
  }
});

const start = async () => {
  try {
    await app.listen({ port: 4000 });
  } catch (err) {
    app.log.error(err);
    process.exit(1);
  }
};

start();

后端的关键点在于:

  1. 双JWT实例: 使用 namespace 区分处理 Access Token 和 Refresh Token 的逻辑。一个从 Authorization 头读取,一个从 cookie 读取。
  2. HttpOnly Cookie: 这是存储 Refresh Token 的标准安全实践,客户端JavaScript无法直接访问它,有效降低了XSS风险。
  3. 清晰的错误响应: 当 Access Token 失效时,GraphQL 端点必须返回一个明确的、可被机器解析的401状态码和错误结构(例如,包含 extensions: { code: 'UNAUTHENTICATED' }),这是前端实现自动刷新的契约。

第二步:构建具备刷新与重试能力的 Relay 网络层

这是整个方案的核心。Relay 通过 Network 层与 GraphQL 服务器通信。默认实现非常简单,但我们可以通过自定义 fetch 函数来注入复杂的逻辑。

我们的目标是实现一个 fetch 函数,它能:

  1. 正常发送请求。
  2. 当接收到 401 响应时,暂停所有后续请求。
  3. 发起一次 Token 刷新请求。
  4. 刷新成功后,使用新的 Token 重新发送之前失败的请求以及所有被暂停的请求。
  5. 处理刷新失败的情况(例如 Refresh Token 也过期了),此时应清理状态并登出用户。
  6. 处理并发的 401 错误,确保只有一个刷新请求被发出。
// RelayEnvironment.js

import {
  Environment,
  Network,
  RecordSource,
  Store,
  type RequestParameters,
  type Variables,
} from 'relay-runtime';

// === 状态管理 ===
// 在真实项目中,这些应由状态管理库(如 Zustand, Redux)管理
let accessToken = null;
let isRefreshing = false;
let requestQueue = [];

// 将请求添加到队列,并返回一个 Promise,该 Promise 在请求被处理时 resolve
const addRequestToQueue = (request) => {
  const { operation, variables, resolve, reject } = request;
  requestQueue.push({ operation, variables, resolve, reject });
};

// 使用新 Token 处理所有排队的请求
const processQueue = (newAccessToken) => {
  requestQueue.forEach(req => {
    fetchGraphQL(req.operation, req.variables, newAccessToken)
      .then(req.resolve)
      .catch(req.reject);
  });
  requestQueue = [];
};

// 当刷新失败时,拒绝所有排队的请求
const rejectQueue = (error) => {
  requestQueue.forEach(req => {
    req.reject(error);
  });
  requestQueue = [];
};

// 更新本地存储的 Access Token
const setAccessToken = (token) => {
  accessToken = token;
  // 在真实应用中,你可能还会将它存储在内存或 sessionStorage 中
};

// 核心的刷新逻辑
const refreshToken = async () => {
  // isRefreshing 作为一个简单的锁,防止并发刷新
  if (isRefreshing) {
    return; // 已经在刷新中,让后续请求等待
  }
  isRefreshing = true;

  try {
    // 注意:fetch 必须配置为 'include' credentials 才能发送 cookie
    const response = await fetch('/refresh_token', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      credentials: 'include',
    });

    if (!response.ok) {
      throw new Error('Failed to refresh token');
    }

    const { accessToken: newAccessToken } = await response.json();
    setAccessToken(newAccessToken);
    processQueue(newAccessToken); // 成功后处理队列
    return newAccessToken;
  } catch (error) {
    console.error('Token refresh failed:', error);
    setAccessToken(null); // 清理 Token
    rejectQueue(error); // 拒绝所有等待的请求
    // 触发全局登出逻辑
    // window.location.href = '/login'; 
    throw error;
  } finally {
    isRefreshing = false;
  }
};


// 封装的 GraphQL fetch 函数
const fetchGraphQL = async (params, variables, currentAccessToken) => {
  const response = await fetch('http://localhost:4000/graphql', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      ...(currentAccessToken && { 'Authorization': `Bearer ${currentAccessToken}` }),
    },
    body: JSON.stringify({
      query: params.text,
      variables,
    }),
  });

  if (response.status === 401) {
    // 这是一个关键检查点
    const errorPayload = await response.json();
    if (errorPayload?.errors?.[0]?.extensions?.code === 'UNAUTHENTICATED') {
      // 抛出一个特定类型的错误,以便被上层捕获和处理
      const authError = new Error('Authentication Error');
      authError.name = 'AuthError';
      throw authError;
    }
  }

  if (!response.ok) {
      // 处理其他网络或服务器错误
      console.error(`HTTP error! status: ${response.status}`, await response.text());
      throw new Error(`Request failed with status ${response.status}`);
  }

  return response.json();
};

// Relay Network 层的 fetch 函数实现
async function fetchRelay(operation, variables) {
  return new Promise((resolve, reject) => {
    const executeRequest = async (token) => {
      try {
        const data = await fetchGraphQL(operation, variables, token);
        resolve(data);
      } catch (error) {
        // 捕获到我们自定义的认证错误
        if (error.name === 'AuthError') {
          // 将当前失败的请求放入队列
          addRequestToQueue({ operation, variables, resolve, reject });

          // 只有第一个触发401的请求会实际执行刷新逻辑
          // isRefreshing 锁保证了这一点
          if (!isRefreshing) {
            refreshToken().catch(refreshError => {
              // 刷新失败时,rejectQueue 已经处理了队列
              // 此处无需额外操作,因为 Promise 链已断开
              console.error('Caught refresh error at top level', refreshError);
            });
          }
        } else {
          // 其他类型的错误直接拒绝
          reject(error);
        }
      }
    };

    executeRequest(accessToken);
  });
}

// 导出 Relay Environment 实例
export const RelayEnvironment = new Environment({
  network: Network.create(fetchRelay),
  store: new Store(new RecordSource()),
});

代码中的设计决策:

  1. Promise 封装: fetchRelay 返回一个 Promise,使得它可以将异步的刷新和重试逻辑完全封装起来,对 Relay 的上层调用者透明。
  2. 请求队列 (requestQueue): 这是解决并发问题的核心。所有在刷新期间进来的请求,以及第一个触发401的请求,都会被推入这个队列。
  3. 锁机制 (isRefreshing): 一个简单的布尔值标志,确保 refreshToken 函数在任何时刻只被执行一次。后续的401错误只会将请求入队,而不会触发新的刷新。
  4. 自定义错误类型 (AuthError): 通过在 fetchGraphQL 中检测特定的401响应并抛出自定义错误,我们可以在 fetchRelaycatch 块中精确地识别出需要刷新Token的场景,而不是误判其他网络错误。
  5. 凭证发送: 调用 /refresh_tokenfetch 必须包含 credentials: 'include' 选项,这样浏览器才会自动带上存储在 HttpOnly cookie 中的 Refresh Token。这是一个常见的坑。

执行流程的可视化

用 Mermaid 图来描绘这个流程会更清晰。

sequenceDiagram
    participant C as Client (Relay)
    participant NL as Network Layer
    participant S as Server (Fastify)
    participant Auth as Auth Server

    C->>NL: Request A (useQuery)
    C->>NL: Request B (useQuery)
    NL->>S: Send Request A (accessToken)
    NL->>S: Send Request B (accessToken)
    
    Note over S: Access Token Expired
    S-->>NL: Response A (401 UNAUTHENTICATED)
    
    par
        NL-->>C: (Request A is now pending in queue)
        Note over NL: Request A failed. Acquire lock.
        NL->>Auth: POST /refresh_token
    and
        S-->>NL: Response B (401 UNAUTHENTICATED)
        NL-->>C: (Request B is now pending in queue)
        Note over NL: Request B failed. Lock is held. Enqueue.
    end
    
    C->>NL: Request C (new query)
    Note over NL: Lock is held. Enqueue Request C.
    NL-->>C: (Request C is now pending in queue)

    Auth-->>NL: New Access Token
    Note over NL: Refresh successful. Release lock.
    
    NL->>S: Retry Request A (new token)
    NL->>S: Retry Request B (new token)
    NL->>S: Send Request C (new token)

    S-->>NL: Response A (200 OK)
    NL-->>C: Resolve Request A
    S-->>NL: Response B (200 OK)
    NL-->>C: Resolve Request B
    S-->>NL: Response C (200 OK)
    NL-->>C: Resolve Request C

这个图清晰地展示了:当请求A和B并发失败时,只有一次 /refresh_token 调用。在刷新期间到达的新请求C也被自动排队。刷新成功后,所有等待的请求都被无缝重试。

当前方案的局限性与未来迭代方向

这套机制在绝大多数场景下工作得很好,但作为工程师,我们需要客观评估其边界。

  1. 跨 Tab 页的重复刷新: 当前的锁(isRefreshing)和队列(requestQueue)都存在于单个页面的 JavaScript 内存中。如果用户打开了多个应用 Tab 页,每个 Tab 可能会在同一时间独立发起 Token 刷新请求。一个可行的优化路径是使用 SharedWorkerBroadcastChannel API 来在多个 Tab 间同步刷新状态,确保整个浏览器实例中只有一个刷新流程在进行。

  2. Refresh Token 的静默失效: 如果用户长时间未关闭页面,Refresh Token 本身也可能过期。当前的实现会在刷新失败后将用户登出,这是合理的。但在某些对用户体验要求极致的应用中,可以考虑在 Refresh Token 即将过期前(例如,有效期剩余24小时),在用户活跃时主动进行一次“预刷新”,用旧的 Refresh Token 换取一个新的 Refresh Token 和 Access Token,从而实现更长的无缝登录周期。这被称为 Refresh Token Rotation。

  3. 服务器端撤销: 此方案依赖于 Refresh Token 的自然过期。如果需要立即撤销某个用户的会话(例如,用户修改密码或发现安全风险),后端需要维护一个已撤销 Refresh Token 的列表(黑名单)。刷新端点在签发新 Token 前,必须检查当前的 Refresh Token 是否在该列表中。这增加了后端的复杂性,是在高安全性需求下的一个必要权衡。

  4. 网络抖动与重试: 当前代码没有处理 /refresh_token 请求本身的网络失败。在生产环境中,应该为这个关键请求增加有限的重试逻辑(例如,使用指数退避策略),以应对暂时的网络中断。


  目录