基于Fastify与TypeScript在GKE环境中实现健壮的OIDC认证流


我们团队在GKE上运行着数十个微服务,统一身份认证始终是个绕不开的痛点。最初各个服务自己维护用户体系,后来引入了统一的身份提供商(IdP),但每个服务都去直接对接OIDC协议,代码重复、配置繁杂,安全策略也难以统一。一个服务出现OIDC库的漏洞,就得逐个排查升级。这种架构在团队扩张后,维护成本变得无法接受。

我们的目标很明确:构建一个独立的、轻量级的认证网关服务。它作为唯一的OIDC客户端(Relying Party),处理与IdP的所有交互,完成后为用户建立一个内部会话,下游的业务服务只需验证这个内部会話即可。

技术选型上,Node.js生态是首选,因为它启动快、资源占用低,非常适合做网关这类IO密集型应用。而在众多框架中,我们放弃了Express,选择了Fastify。原因无他,性能、开箱即用的schema验证、以及对TypeScript的优秀支持,都使其成为构建一个严肃的、生产级服务的更优选择。

这篇记录将复盘我们如何使用Fastify、TypeScript和openid-client库,在GKE环境中从零构建这个认证服务,并重点讨论在容器化环境中处理会话、安全配置以及代码规范的实践。

第一步:奠定坚实的项目基础

一个安全攸关的服务,项目初始化阶段的规范性至关重要。这不仅是关于代码风格,更是关于可维护性和减少低级错误。

我们使用pnpm作为包管理器,并立即引入TypeScript、ESLint和Prettier。

package.json 核心依赖:

{
  "name": "auth-gateway",
  "version": "1.0.0",
  "main": "dist/server.js",
  "scripts": {
    "build": "tsc",
    "start": "node dist/server.js",
    "dev": "ts-node-dev --respawn --transpile-only src/server.ts",
    "lint": "eslint . --ext .ts",
    "lint:fix": "eslint . --ext .ts --fix"
  },
  "dependencies": {
    "@fastify/cookie": "^9.1.0",
    "@fastify/session": "^10.5.0",
    "connect-redis": "^7.1.0",
    "dotenv": "^16.3.1",
    "fastify": "^4.23.2",
    "ioredis": "^5.3.2",
    "openid-client": "^5.6.1",
    "pino-pretty": "^10.2.3"
  },
  "devDependencies": {
    "@types/node": "^20.8.7",
    "@typescript-eslint/eslint-plugin": "^6.8.0",
    "@typescript-eslint/parser": "^6.8.0",
    "eslint": "^8.51.0",
    "eslint-config-prettier": "^9.0.0",
    "eslint-plugin-prettier": "^5.0.1",
    "prettier": "^3.0.3",
    "ts-node-dev": "^2.0.0",
    "typescript": "^5.2.2"
  }
}

tsconfig.json 的关键配置:

这里的strict模式是强制性的。对于认证服务,任何类型不明确的地方都是潜在的安全隐患。

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "commonjs",
    "rootDir": "./src",
    "outDir": "./dist",
    "esModuleInterop": true,
    "forceConsistentCasingInFileNames": true,
    "strict": true,
    "skipLibCheck": true
  },
  "include": ["src/**/*"]
}

代码规范我们使用ESLint配合Prettier,规则非常严格。例如,禁止使用any类型,强制所有分支都有返回值等。

第二步:配置管理与OIDC客户端初始化

在GKE这类环境中,配置必须通过环境变量注入,任何硬编码都是不可接受的。我们创建一个专门的配置模块来加载和校验环境变量。

src/config.ts

import 'dotenv/config'; // 用于本地开发加载.env文件

function getEnv(key: string): string {
  const value = process.env[key];
  if (!value) {
    throw new Error(`Missing environment variable: ${key}`);
  }
  return value;
}

export const config = {
  // 服务配置
  server: {
    host: process.env.HOST || '0.0.0.0',
    port: parseInt(process.env.PORT || '8080', 10),
    logLevel: process.env.LOG_LEVEL || 'info',
  },
  // OIDC提供商配置
  oidc: {
    issuer: getEnv('OIDC_ISSUER_URL'),
    clientId: getEnv('OIDC_CLIENT_ID'),
    clientSecret: getEnv('OIDC_CLIENT_SECRET'),
    redirectUri: getEnv('OIDC_REDIRECT_URI'),
    // 通常是 openid profile email
    scope: process.env.OIDC_SCOPE || 'openid profile email',
  },
  // Redis会话存储配置
  redis: {
    host: getEnv('REDIS_HOST'),
    port: parseInt(process.env.REDIS_PORT || '6379', 10),
  },
  // Cookie安全配置
  cookie: {
    secret: getEnv('COOKIE_SECRET'),
    // 在生产环境中必须为 true
    secure: process.env.NODE_ENV === 'production',
    // 限制在同站发送
    sameSite: 'lax' as const,
  },
};

这里的getEnv函数确保了服务在启动时如果缺少关键配置会立刻失败,而不是在运行时才报错。

接下来,初始化openid-client。一个常见的错误是在每次请求中都去初始化客户端或重新发现(discover)IdP的元数据,这会带来不必要的网络开销和延迟。正确的做法是在服务启动时初始化一次,并作为单例使用。我们通过Fastify的decorate机制将其封装成插件。

src/plugins/oidcClient.ts

import { FastifyInstance } from 'fastify';
import fp from 'fastify-plugin';
import { Issuer, Client, custom } from 'openid-client';
import { config } from '../config';

// 扩展FastifyInstance类型,让TypeScript知道oidcClient的存在
declare module 'fastify' {
  interface FastifyInstance {
    oidcClient: Client;
  }
}

async function oidcClientPlugin(fastify: FastifyInstance) {
  // 在真实项目中,你可能需要配置自定义的HTTP Agent来处理代理或超时
  // custom.setHttpOptionsDefaults({ timeout: 5000 });
  
  try {
    const oidcIssuer = await Issuer.discover(config.oidc.issuer);
    
    const client = new oidcIssuer.Client({
      client_id: config.oidc.clientId,
      client_secret: config.oidc.clientSecret,
      redirect_uris: [config.oidc.redirectUri],
      response_types: ['code'],
    });

    // 将初始化好的客户端实例装饰到Fastify实例上
    fastify.decorate('oidcClient', client);
    fastify.log.info('OIDC client initialized successfully');

  } catch (error) {
    fastify.log.error({ err: error }, 'Failed to initialize OIDC client');
    // 在启动阶段失败,直接退出进程
    process.exit(1);
  }
}

export default fp(oidcClientPlugin);

第三步:会话管理 - Stateful vs. Stateless 的抉择

在Kubernetes环境中,服务实例是“易逝”的,可能会随时被调度、重启或扩缩容。这使得会话管理策略的选择尤为重要。

  1. Stateless (无状态): 将OIDC的id_token或一个自定义的JWT直接返回给前端,前端在后续请求中通过Authorization头携带。

    • 优点: 扩展性好,服务端无需存储任何会话信息。
    • 缺点: 令牌一旦签发,在过期前无法主动撤销。如果用户登出或权限变更,令牌依然有效。对于安全要求高的场景,这是个大问题。令牌通常较大,每次请求都携带会增加网络开销。
  2. Stateful (有状态): 服务端通过一个随机生成的session_id来维护会话,并将此ID存储在客户端的HttpOnly Cookie中。会话数据(包括OIDC令牌和用户信息)存储在外部存储中,如Redis。

    • 优点: 可以随时在服务端撤销会话(删除Redis中的记录),安全性更高。Cookie对前端透明,无需前端额外处理。
    • 缺点: 引入了对外部存储(Redis)的依赖,架构上增加了一个故障点。

在我们的场景中,强制用户登出和及时响应权限变更的需求是刚性的。因此,我们选择了有状态的方案,使用Redis作为会话存储。@fastify/sessionconnect-redis让这个过程变得非常简单。

src/server.ts 中注册会话插件

import fastify from 'fastify';
import cookie from '@fastify/cookie';
import session from '@fastify/session';
import RedisStore from 'connect-redis';
import Redis from 'ioredis';

// ... 其他 imports
import { config } from './config';

async function buildServer() {
  const app = fastify({
    logger: {
      level: config.server.logLevel,
      transport: config.server.logLevel === 'info' 
        ? { target: 'pino-pretty' } 
        : undefined,
    },
  });

  // 注册cookie插件
  await app.register(cookie);

  // 初始化Redis客户端
  const redisClient = new Redis({
    host: config.redis.host,
    port: config.redis.port,
  });

  // 注册session插件
  await app.register(session, {
    secret: config.cookie.secret,
    store: new RedisStore({ client: redisClient }),
    cookie: {
      secure: config.cookie.secure,
      httpOnly: true,
      sameSite: config.cookie.sameSite,
      maxAge: 24 * 60 * 60 * 1000, // 24 hours
    },
  });

  // ... 注册其他插件和路由
  
  return app;
}

这里的cookie配置至关重要。secure: true确保Cookie只在HTTPS下传输,httpOnly: true防止客户端脚本读取Cookie,sameSite: 'lax'提供了对CSRF攻击的基础防护。

第四步:实现OIDC核心认证流程

整个流程可以被一个Mermaid图清晰地表示出来:

sequenceDiagram
    participant User as 用户浏览器
    participant App as 业务应用前端
    participant Gateway as Fastify认证网关
    participant IdP as OIDC身份提供商
    participant Redis

    User->>App: 访问受保护页面
    App->>Gateway: 发起请求 (无会话)
    Gateway->>Gateway: 检查会话, 发现不存在
    Note over Gateway: 生成 state, nonce, code_verifier
    Gateway->>Redis: 存储 state, nonce, code_verifier 到session
    Gateway-->>User: 302重定向至IdP认证URL (含code_challenge)
    User->>IdP: 输入凭证进行登录
    IdP-->>User: 302重定向至网关的回调URL (含code, state)
    User->>Gateway: 访问 /auth/callback?code=...&state=...
    Gateway->>Redis: 获取session中的 state, nonce, code_verifier
    Note over Gateway: 校验state防止CSRF
    Gateway->>IdP: 发送code, code_verifier, client credentials
    IdP->>Gateway: 返回 id_token, access_token, refresh_token
    Note over Gateway: 校验 id_token (issuer, audience, nonce, etc.)
    Gateway->>Redis: 将tokens和用户信息存入session
    Gateway-->>User: 302重定向至最初访问的应用页面
    User->>App: 再次访问受保护页面 (携带会话Cookie)
    App->>Gateway: 发起请求 (有会话)
    Gateway->>Redis: 验证会话有效
    Gateway-->>App: 返回受保护资源

1. 登录路由 (/auth/login)

此路由负责启动整个OIDC流程。

src/routes/auth.ts

import { FastifyInstance } from 'fastify';
import { generators } from 'openid-client';

export async function authRoutes(fastify: FastifyInstance) {
  fastify.get('/login', async (request, reply) => {
    // PKCE (Proof Key for Code Exchange) 是现代OIDC流程的强制要求
    const code_verifier = generators.codeVerifier();
    const code_challenge = generators.codeChallenge(code_verifier);
    const nonce = generators.nonce();
    const state = generators.state();

    // 将验证需要的数据存入会话
    request.session.set('oidc', {
      code_verifier,
      nonce,
      state,
      // 存储用户登录后应该被重定向到的原始URL
      returnTo: request.query.returnTo || '/',
    });
    
    const authorizationUrl = fastify.oidcClient.authorizationUrl({
      scope: 'openid profile email',
      code_challenge,
      code_challenge_method: 'S256',
      nonce,
      state,
    });

    return reply.redirect(authorizationUrl);
  });
}

这里的坑在于statenonce的正确使用。state用于防止CSRF攻击,确保回调请求是由我们的应用发起的。nonce用于防止重放攻击,确保收到的id_token是针对本次登录请求签发的。PKCE则防止了授权码被截获后盗用的风险。

2. 回调路由 (/auth/callback)

这是最核心也最容易出错的部分。它接收来自IdP的授权码,并将其交换为令牌。

// 继续在 src/routes/auth.ts 中

export async function authRoutes(fastify: FastifyInstance) {
  // ... login route

  fastify.get('/callback', async (request, reply) => {
    const sessionData = request.session.get('oidc');
    if (!sessionData) {
      // 如果会话中没有OIDC数据,说明流程异常,返回错误
      return reply.code(400).send({ error: 'Invalid OIDC session state' });
    }

    try {
      // 使用 openid-client 提供的 params 方法来解析请求参数
      const params = fastify.oidcClient.callbackParams(request.raw);
      
      // 执行回调,交换令牌,并进行所有必要的校验
      const tokenSet = await fastify.oidcClient.callback(
        config.oidc.redirectUri,
        params,
        {
          // 这里的检查是 `openid-client` 自动执行的
          code_verifier: sessionData.code_verifier,
          state: sessionData.state,
          nonce: sessionData.nonce,
        }
      );
      
      const claims = tokenSet.claims();
      
      // 清理临时的OIDC会话数据
      request.session.set('oidc', undefined);
      
      // 存储用户信息和令牌到主会话中
      request.session.set('user', {
        id: claims.sub,
        email: claims.email,
        name: claims.name,
      });
      request.session.set('tokens', {
        id_token: tokenSet.id_token,
        access_token: tokenSet.access_token,
        refresh_token: tokenSet.refresh_token,
        expires_at: tokenSet.expires_at,
      });

      return reply.redirect(sessionData.returnTo || '/');

    } catch (err: any) {
      request.log.error({ err }, 'OIDC callback error');
      return reply.code(500).send({ error: 'Authentication failed', message: err.message });
    }
  });
}

一个常见的错误是手动校验statenonceopenid-client库的callback方法已经内置了这些校验,我们只需要把期望值传给它即可。如果校验失败,它会直接抛出异常,这大大简化了我们的错误处理逻辑。

3. 登出路由 (/auth/logout)

正确的登出不仅要销毁本地会话,还应该尝试通知IdP,以实现单点登出(Single Sign-Out)。

// 继续在 src/routes/auth.ts 中

export async function authRoutes(fastify: FastifyInstance) {
  // ... login and callback routes

  fastify.get('/logout', async (request, reply) => {
    const idToken = request.session.get('tokens')?.id_token;
    
    // 销毁本地会话
    await request.session.destroy();
    
    // 如果IdP支持RP-Initiated Logout,则重定向到登出端点
    try {
      const endSessionUrl = fastify.oidcClient.endSessionUrl({
        id_token_hint: idToken,
        // 登出后IdP重定向回来的地址
        post_logout_redirect_uri: config.oidc.logoutRedirectUri, 
      });
      return reply.redirect(endSessionUrl);
    } catch (err) {
      // 如果IdP不支持或者没有配置,则直接重定向到首页
      request.log.warn('Could not construct end_session_url. Redirecting to home.');
      return reply.redirect('/');
    }
  });
}

第五步:部署到GKE

最后一步是将服务容器化并部署到GKE。

Dockerfile (多阶段构建)

# ---- Base Stage ----
FROM node:18-alpine AS base
WORKDIR /app
COPY package.json pnpm-lock.yaml ./
RUN npm i -g pnpm

# ---- Dependencies Stage ----
FROM base AS dependencies
RUN pnpm fetch
RUN pnpm install --prod --frozen-lockfile

# ---- Build Stage ----
FROM base AS build
COPY . .
RUN pnpm install --frozen-lockfile
RUN pnpm build

# ---- Production Stage ----
FROM node:18-alpine AS production
WORKDIR /app
COPY --from=dependencies /app/node_modules ./node_modules
COPY --from=build /app/dist ./dist

# 使用非root用户运行
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
USER appuser

EXPOSE 8080
CMD ["node", "dist/server.js"]

多阶段构建可以显著减小最终镜像的体积,并移除构建时依赖,提升安全性。使用非root用户运行是容器安全的最佳实践。

GKE deployment.yaml 关键部分

apiVersion: apps/v1
kind: Deployment
metadata:
  name: auth-gateway
spec:
  replicas: 2
  template:
    spec:
      containers:
        - name: auth-gateway
          image: gcr.io/your-project/auth-gateway:latest
          ports:
            - containerPort: 8080
          env:
            - name: NODE_ENV
              value: "production"
            - name: OIDC_ISSUER_URL
              value: "https://accounts.google.com" # 示例
            - name: OIDC_REDIRECT_URI
              value: "https://your-app.com/auth/callback"
            # 密钥应该从Kubernetes Secrets中获取
            - name: OIDC_CLIENT_ID
              valueFrom:
                secretKeyRef:
                  name: oidc-credentials
                  key: client-id
            - name: OIDC_CLIENT_SECRET
              valueFrom:
                secretKeyRef:
                  name: oidc-credentials
                  key: client-secret
            - name: COOKIE_SECRET
              valueFrom:
                secretKeyRef:
                  name: oidc-credentials
                  key: cookie-secret
            - name: REDIS_HOST
              value: "redis-service.default.svc.cluster.local"
          # 配置资源限制和存活探针
          resources:
            requests:
              cpu: "100m"
              memory: "128Mi"
            limits:
              cpu: "250m"
              memory: "256Mi"
          livenessProbe:
            httpGet:
              path: /health
              port: 8080
            initialDelaySeconds: 15
            periodSeconds: 20
          readinessProbe:
            httpGet:
              path: /health
              port: 8080
            initialDelaySeconds: 5
            periodSeconds: 10

在生产环境中,绝对不能将密钥直接写入YAML或通过环境变量明文传递。正确的方式是使用Kubernetes Secrets,并通过valueFrom.secretKeyRef注入到容器中。

局限性与未来迭代

这套方案已经能满足我们当前的核心需求,但它并非完美。

首先,令牌刷新机制尚未实现。当前实现中,一旦会话(和内部存储的令牌)过期,用户必须重新登录。一个改进是在认证钩子中检查access_token的过期时间,如果即将过期,则使用refresh_token在后台静默刷新,这对用户体验是透明的。

其次,当前的Redis会话存储是一个单点。虽然在GKE中可以部署高可用的Redis(例如使用Redis Sentinel或Cluster),但这增加了架构的复杂性。对于某些允许一定安全风险以换取更高可用性的场景,可以重新评估无状态JWT方案,并结合短时效令牌和高效的吊销列表(CRL)作为折衷。

最后,服务本身的高可用依赖于GKE的调度和副本机制。对于认证这种关键服务,需要配置更严格的Pod反亲和性规则,确保实例分散在不同的物理节点上,并设置合适的PodDisruptionBudget来保证在节点维护期间服务的可用性。


  目录