195 lines
8.2 KiB
JavaScript
195 lines
8.2 KiB
JavaScript
/**
|
||
* 夹具 —— 平台侧「对外授权面」(`/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()
|
||
})
|