---
title: "Node.js BFF 示例 - QiSSO"
description: "用 Node.js 与 openid-client 跑通授权码、S256 PKCE、回调验证和本地业务会话。"
canonical_url: "https://docs.mcent.cn/qisso/node-bff-example"
---
# Node.js BFF 示例 - QiSSO

> 用 Node.js 与 openid-client 跑通授权码、S256 PKCE、回调验证和本地业务会话。

本例适合先验证“注册应用 → 跳转 QiSSO → 回调 → 业务会话”的完整链路。使用 Node.js 24、标准 HTTP 服务和已验证的 `openid-client` 6.8.9，不依赖前端框架。

这是仅监听本机的开发示例。它使用进程内事务与会话、普通 HTTP 本机 Cookie，不包含持久会话、刷新、资源权限、授权重新核验或多实例协调。生产实现继续使用业务框架的会话、CSRF 与密钥管理能力。

## 1. 注册示例应用

在测试用客户端中登记：

```text
应用首页：http://127.0.0.1:3602
回调：http://127.0.0.1:3602/auth/callback
客户端类型：confidential
认证方式：client_secret_basic
授权流程：authorization_code
响应类型：code
PKCE：启用
Scope：openid profile email
```

Client ID 与 Secret 由管理员提供，不使用本例中的示意标识作为真实凭据。`localhost` 与 `127.0.0.1` 是不同来源，使用时不要混用。

## 2. 准备目录与配置

在独立示例目录安装依赖，并保留生成的锁文件：

```bash
npm init -y
npm install openid-client@6.8.9
```

创建仅供本机使用且已被 Git 忽略的 `.env`：

```dotenv
OIDC_ISSUER=https://account.mcent.cn/realms/mcent
OIDC_CLIENT_ID=your-registered-client-id
OIDC_CLIENT_SECRET=your-one-time-generated-secret
```

这里是字段示意，不是可用密钥。不要把 `.env` 提交到仓库或复制到浏览器环境变量中。

## 3. 创建服务

将下方代码保存为 `server.mjs`。它固定使用 query 回调；不要为它单独配置 `response_mode=form_post`。

```js
import { createServer } from 'node:http'
import { randomBytes } from 'node:crypto'
import * as oidc from 'openid-client'

const origin = 'http://127.0.0.1:3602'
const callback = `${origin}/auth/callback`
const issuer = new URL(process.env.OIDC_ISSUER || '')
const clientId = process.env.OIDC_CLIENT_ID
const secret = process.env.OIDC_CLIENT_SECRET
if (issuer.protocol !== 'https:' || !clientId || !secret) {
  throw new Error('需要 HTTPS Issuer、Client ID 与服务端 Secret')
}
const config = await oidc.discovery(
  issuer, clientId,
  { id_token_signed_response_alg: 'RS256' },
  oidc.ClientSecretBasic(secret)
)
// 即使 token 通过 TLS 返回，也显式验证 ID Token 的 JWS 签名。
oidc.enableNonRepudiationChecks(config)

// 开发演示：单进程内存存储，重启即失效；生产替换为框架的受控会话存储。
const transactions = new Map()
const sessions = new Map()
const randomId = () => randomBytes(32).toString('base64url')
const cookie = (name, value, age) =>
  `${name}=${value}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${age}`
function cookieValue(req, name) {
  const values = (req.headers.cookie || '').split(';')
    .map(part => part.trim()).filter(part => part.startsWith(`${name}=`))
  return values.length === 1 ? values[0].slice(name.length + 1) : undefined
}
function purgeExpired() {
  for (const store of [transactions, sessions]) {
    for (const [key, value] of store) {
      if (value.expiresAt <= Date.now()) store.delete(key)
    }
  }
}
function redirect(res, location) {
  res.writeHead(303, { location })
  res.end()
}
function json(res, status, value) {
  res.writeHead(status, { 'content-type': 'application/json; charset=utf-8' })
  res.end(JSON.stringify(value))
}

createServer(async (req, res) => {
  res.setHeader('Cache-Control', 'no-store')
  res.setHeader('Referrer-Policy', 'no-referrer')
  purgeExpired()
  try {
    const url = new URL(req.url, origin)
    if (req.method === 'GET' && url.pathname === '/') {
      res.setHeader('Content-Type', 'text/html; charset=utf-8')
      res.end('<h1>QiSSO 本机联调</h1><a href="/auth/login">登录</a> | '
        + '<a href="/api/me">查看本地身份</a>'
        + '<form method="post" action="/auth/logout"><button>退出本地会话</button></form>')
      return
    }
    if (req.method === 'GET' && url.pathname === '/auth/login') {
      const oldId = cookieValue(req, 'qisso_demo_tx')
      transactions.delete(oldId)
      const txId = randomId()
      const state = oidc.randomState()
      const nonce = oidc.randomNonce()
      const verifier = oidc.randomPKCECodeVerifier()
      transactions.set(txId, { state, nonce, verifier, expiresAt: Date.now() + 300_000 })
      const target = oidc.buildAuthorizationUrl(config, {
        redirect_uri: callback,
        response_type: 'code', scope: 'openid profile email', state, nonce,
        code_challenge: await oidc.calculatePKCECodeChallenge(verifier),
        code_challenge_method: 'S256'
      })
      res.setHeader('Set-Cookie', cookie('qisso_demo_tx', txId, 300))
      redirect(res, target.href)
      return
    }
    if (req.method === 'GET' && url.pathname === '/auth/callback') {
      const txId = cookieValue(req, 'qisso_demo_tx')
      const tx = transactions.get(txId)
      // 换码前消费，重复回调不能再次换码；失败后重新发起登录。
      transactions.delete(txId)
      res.setHeader('Set-Cookie', cookie('qisso_demo_tx', '', 0))
      if (!tx || tx.expiresAt <= Date.now()) throw new Error('LOGIN_TRANSACTION_EXPIRED')
      const tokens = await oidc.authorizationCodeGrant(config, url, {
        pkceCodeVerifier: tx.verifier,
        expectedState: tx.state,
        expectedNonce: tx.nonce,
        idTokenExpected: true
      })
      const claims = tokens.claims()
      if (!claims?.sub) throw new Error('MISSING_SUBJECT')
      const sessionId = randomId()
      sessions.delete(cookieValue(req, 'qisso_demo_session'))
      const expiresAt = Math.min(Date.now() + 900_000, claims.exp * 1000)
      sessions.set(sessionId, {
        expiresAt,
        user: { issuer: claims.iss, sub: claims.sub, name: claims.name || null }
      })
      res.setHeader('Set-Cookie', [
        cookie('qisso_demo_tx', '', 0),
        cookie('qisso_demo_session', sessionId, Math.max(0, Math.floor((expiresAt - Date.now()) / 1000)))
      ])
      redirect(res, '/api/me')
      return
    }
    if (req.method === 'GET' && url.pathname === '/api/me') {
      const session = sessions.get(cookieValue(req, 'qisso_demo_session'))
      json(res, session ? 200 : 401, session?.user || { error: 'not_authenticated' })
      return
    }
    if (req.method === 'POST' && url.pathname === '/auth/logout') {
      if (req.headers.origin !== origin) {
        json(res, 403, { error: 'invalid_origin' })
        return
      }
      sessions.delete(cookieValue(req, 'qisso_demo_session'))
      res.setHeader('Set-Cookie', cookie('qisso_demo_session', '', 0))
      redirect(res, '/')
      return
    }
    json(res, 404, { error: 'not_found' })
  } catch {
    // 不输出异常对象，避免协议错误携带 code、Token 或响应体。
    res.setHeader('Set-Cookie', cookie('qisso_demo_tx', '', 0))
    json(res, 400, { error: 'login_failed', message: '本次登录未完成，请重新发起。' })
  }
}).listen(3602, '127.0.0.1', () => console.log(origin))
```

示例只返回最小的本地身份，不将 Token 响应或原始 Claims 整体下发浏览器。依赖调用依据 [openid-client 官方 API](https://github.com/panva/openid-client/tree/main/docs)，安装时固定你已验证的 6.x 版本与锁文件。

## 4. 启动并联调

```bash
node --env-file=.env server.mjs
```

打开 `http://127.0.0.1:3602`，点击登录。完成 QiSSO 登录与应用授权后，页面跳转 `/api/me`，返回 `issuer`、`sub` 和可选 `name`。不持有本地会话时该接口返回 401。

若正式实例不允许本机回调，由管理员提供测试环境或已批准的 HTTPS 联调域名，再把服务改为使用该固定外部 Origin；不要为了跑示例放宽生产回调或关闭 TLS 校验。

## 5. 核对正常与失败结果

<table>
<thead>
  <tr>
    <th>
      操作
    </th>
    
    <th>
      期望
    </th>
  </tr>
</thead>

<tbody>
  <tr>
    <td>
      首次完成登录
    </td>
    
    <td>
      <code>
        /api/me
      </code>
      
       返回已验证的稳定身份
    </td>
  </tr>
  
  <tr>
    <td>
      刷新 <code>
        /api/me
      </code>
    </td>
    
    <td>
      有效 Cookie 下继续返回同一身份
    </td>
  </tr>
  
  <tr>
    <td>
      无事务直接访问回调
    </td>
    
    <td>
      400，不创建会话
    </td>
  </tr>
  
  <tr>
    <td>
      修改 state、nonce、aud 或签名
    </td>
    
    <td>
      拒绝登录
    </td>
  </tr>
  
  <tr>
    <td>
      重复使用同一回调
    </td>
    
    <td>
      400，不能再次消费事务
    </td>
  </tr>
  
  <tr>
    <td>
      本地退出
    </td>
    
    <td>
      Cookie 与服务端会话清除，<code>
        /api/me
      </code>
      
       返回 401
    </td>
  </tr>
  
  <tr>
    <td>
      非本站 Origin 请求退出
    </td>
    
    <td>
      403，不执行退出
    </td>
  </tr>
</tbody>
</table>

代码已通过协议模拟测试的结果，只证明示例的参数处理与安全校验；正式环境的真实登录仍需你使用已注册客户端完成，不能把模拟结果写成真实账号登录验收。

## 接入业务工程时补齐什么

将内存存储换为现有会话组件，配置生产 HTTPS Cookie、有效期、资源限制与多实例共享；按需安全保存令牌与协调刷新。将已验证 `(iss, sub)` 映射到本地用户，再实施[业务权限](https://docs.mcent.cn/qisso/claims-and-permissions)与[授权重新核验](https://docs.mcent.cn/qisso/server-integration)。退出策略见[会话与退出](https://docs.mcent.cn/qisso/sessions-and-logout)。


## Sitemap

See the full [sitemap](https://docs.mcent.cn/sitemap.md) for all pages.
