/** * 夹具 —— 平台侧「对外授权面」(`/oauth/{authorize,token,userinfo}`) * ============================================================================ * 判据分四类: * ① **闸门**:客户端不对必拒、回调不在白名单必拒、缺 PKCE 必拒、无会话先去登录; * ② **通路**:带门户会话能发码并跳回回调、码能换到访问令牌、令牌能取到四个 claim; * ③ **一次性**:同一个码**不许换第二次**; * ④ **凭据纪律**:密钥不对 401;无令牌/坏令牌 401;⛔ 响应里**不出现** client_secret。 * ⛔ 不联网、不碰真库(`app.db` 用按方法名兜底的桩)。跑法:`node --test test/oauth-provider.test.mjs`(需先 `npm run build`)。 */ import assert from 'node:assert/strict' import { createHash, randomBytes } from 'node:crypto' import test from 'node:test' import Fastify from 'fastify' // ⚠️ 环境变量必须在**导入被测模块之前**设好(模块在导入期读它)。 process.env.DSHS_OAUTH_PROVIDER_CLIENT_ID = 'ai1net-desk' process.env.DSHS_OAUTH_PROVIDER_CLIENT_SECRET = 'test-secret-please-rotate' process.env.DSHS_OAUTH_PROVIDER_REDIRECT_URIS = 'https://desk.ai1net.com/api/v2/auth/oauth/callback' const { oauthProviderRoutes } = await import('../lib/web/routes/oauth-provider.js') const REDIRECT = 'https://desk.ai1net.com/api/v2/auth/oauth/callback' const b64url = (buf) => buf.toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '') const verifier = randomBytes(32).toString('hex') const challenge = b64url(createHash('sha256').update(verifier).digest()) /** 起一个只挂本路由的 app;`db` 用兜底 Proxy(会话/用户查询都返回固定行)。 */ async function makeApp() { const app = Fastify() const userRow = { id: 'u-1', username: 'tester', role: 'active', email: 'tester@example.com' } const sessionRow = { userId: 'u-1', expiresAt: Date.now() + 600000 } // ⚠️ 会话查询要返回**扁平行** `{expiresAt, user}`(`authn.ts#userViaSession` 就是这么读的); // 用普通对象而不是 Proxy:Proxy 在 Fastify `decorate` 之后取不到方法。 app.decorate('db', { findSessionWithUser: async () => ({ expiresAt: sessionRow.expiresAt, user: userRow }), findUserById: async () => userRow, audit: async () => {}, }) app.decorateRequest('user', null) await app.register(oauthProviderRoutes, { prefix: '' }) return app } /** 授权请求的查询串(可覆盖单项)。 */ function authQuery(override = {}) { const q = { client_id: 'ai1net-desk', redirect_uri: REDIRECT, response_type: 'code', state: 'st-1', code_challenge: challenge, code_challenge_method: 'S256', ...override, } return Object.entries(q).map(([k, v]) => `${k}=${encodeURIComponent(String(v))}`).join('&') } test('① 无会话 ⇒ 302 去门户登录(next 带回原地址)', async () => { const app = await makeApp() const res = await app.inject({ method: 'GET', url: `/oauth/authorize?${authQuery()}` }) assert.equal(res.statusCode, 302) assert.match(String(res.headers.location), /^\/login\.html\?next=/) await app.close() }) test('① 客户端不对 ⇒ 400', async () => { const app = await makeApp() const res = await app.inject({ method: 'GET', url: `/oauth/authorize?${authQuery({ client_id: 'nope' })}` }) assert.equal(res.statusCode, 400) await app.close() }) test('① 回调不在白名单 ⇒ 400', async () => { const app = await makeApp() const res = await app.inject({ method: 'GET', url: `/oauth/authorize?${authQuery({ redirect_uri: 'https://evil.example/cb' })}` }) assert.equal(res.statusCode, 400) await app.close() }) test('① 缺 PKCE ⇒ 放行(机密客户端)但走会话闸门;给非 S256 方法 ⇒ 400', async () => { const app = await makeApp() const noPkce = await app.inject({ method: 'GET', url: `/oauth/authorize?${authQuery({ code_challenge: '', code_challenge_method: '' })}`, }) assert.equal(noPkce.statusCode, 302, '没给 PKCE 不该拒,应落到会话闸门(未登录 ⇒ 302 去登录)') assert.match(String(noPkce.headers.location), /^\/login\.html\?next=/) const plain = await app.inject({ method: 'GET', url: `/oauth/authorize?${authQuery({ code_challenge: 'abc', code_challenge_method: 'plain' })}`, }) assert.equal(plain.statusCode, 400, '给了 challenge 就必须是 S256') await app.close() }) test('② 带会话 ⇒ 302 回回调并带 code/state', async () => { const app = await makeApp() const res = await app.inject({ method: 'GET', url: `/oauth/authorize?${authQuery()}`, headers: { cookie: 'sid=whatever-the-stub-accepts' }, }) assert.equal(res.statusCode, 302, `期望 302,实得 ${res.statusCode}: ${res.body}`) const loc = String(res.headers.location) assert.ok(loc.startsWith(REDIRECT), `Location 应在白名单回调上:${loc}`) assert.match(loc, /[?&]code=/) assert.match(loc, /[?&]state=st-1/) await app.close() }) test('②③ 码换票成功、同码不可二次使用', async () => { const app = await makeApp() const auth = await app.inject({ method: 'GET', url: `/oauth/authorize?${authQuery()}`, headers: { cookie: 'sid=whatever' }, }) const code = new URL(String(auth.headers.location)).searchParams.get('code') assert.ok(code && code.length >= 20) const form = new URLSearchParams({ grant_type: 'authorization_code', code: String(code), redirect_uri: REDIRECT, client_id: 'ai1net-desk', client_secret: 'test-secret-please-rotate', code_verifier: verifier, }).toString() const ok = await app.inject({ method: 'POST', url: '/oauth/token', headers: { 'content-type': 'application/x-www-form-urlencoded' }, payload: form, }) assert.equal(ok.statusCode, 200, ok.body) const body = JSON.parse(ok.body) assert.equal(body.token_type, 'Bearer') assert.ok(typeof body.access_token === 'string' && body.access_token.length >= 20) assert.ok(!ok.body.includes('client_secret'), '响应里不得出现客户端密钥') const replay = await app.inject({ method: 'POST', url: '/oauth/token', headers: { 'content-type': 'application/x-www-form-urlencoded' }, payload: form, }) assert.equal(replay.statusCode, 400, '同一个码必须不能二次使用') await app.close() }) test('④ 密钥不对 ⇒ 401;且响应不含密钥', async () => { const app = await makeApp() const form = new URLSearchParams({ grant_type: 'authorization_code', code: 'x', redirect_uri: REDIRECT, client_id: 'ai1net-desk', client_secret: 'wrong', code_verifier: verifier, }).toString() const res = await app.inject({ method: 'POST', url: '/oauth/token', headers: { 'content-type': 'application/x-www-form-urlencoded' }, payload: form, }) assert.equal(res.statusCode, 401) assert.ok(!res.body.includes('test-secret-please-rotate')) await app.close() }) test('② userinfo 回四个 claim;坏令牌 401', async () => { const app = await makeApp() const auth = await app.inject({ method: 'GET', url: `/oauth/authorize?${authQuery()}`, headers: { cookie: 'sid=whatever' } }) const code = new URL(String(auth.headers.location)).searchParams.get('code') const form = new URLSearchParams({ grant_type: 'authorization_code', code: String(code), redirect_uri: REDIRECT, client_id: 'ai1net-desk', client_secret: 'test-secret-please-rotate', code_verifier: verifier, }).toString() const tok = JSON.parse((await app.inject({ method: 'POST', url: '/oauth/token', headers: { 'content-type': 'application/x-www-form-urlencoded' }, payload: form, })).body) const info = await app.inject({ method: 'GET', url: '/oauth/userinfo', headers: { authorization: `Bearer ${tok.access_token}` }, }) assert.equal(info.statusCode, 200, info.body) const claims = JSON.parse(info.body) assert.equal(claims.sub, 'u-1') assert.equal(claims.preferred_username, 'tester') assert.equal(claims.email, 'tester@example.com') assert.equal(claims.name, 'tester') assert.equal((await app.inject({ method: 'GET', url: '/oauth/userinfo' })).statusCode, 401) assert.equal((await app.inject({ method: 'GET', url: '/oauth/userinfo', headers: { authorization: 'Bearer bogus' } })).statusCode, 401) await app.close() })