Files
dsh_shenxian/test/oauth-provider.test.mjs
T
admin 5ec99ce227
build / build-and-scan (push) Canceled after 0s
chore: 文档库全量重新入库(2026-10-20)
2026-10-20 15:05:55 +08:00

195 lines
8.2 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 夹具 —— 平台侧「对外授权面」(`/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: '[email protected]' }
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, '[email protected]')
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()
})