cangku/backend/test/thirdPartySsoService.test.js

271 lines
8.3 KiB
JavaScript

'use strict';
const assert = require('node:assert/strict');
const test = require('node:test');
const jwt = require('jsonwebtoken');
const {
THIRD_PARTY_FLOW,
buildCodeChallenge,
buildUserInfo,
completeThirdPartyAuthorization,
createSsoClient,
exchangeAuthorizationCode,
getThirdPartySsoCallbackBaseUrl,
hashValue,
revokeAccessToken,
rotateSsoClientSecret,
validateAuthorizationRequest,
verifyThirdPartyAccessToken,
} = require('../src/services/thirdPartySsoService');
const ORIGINAL_SECRET = process.env.CUBEJS_SECRET;
process.env.CUBEJS_SECRET = 'third-party-sso-test-secret';
function resolvedUser() {
return {
id: 42,
username: 'operator',
name: '陆南亭',
email: 'operator@example.com',
role: 'user',
status: 'active',
auth_source: 'dingtalk',
departmentId: 8,
departmentName: '运营中心',
allowedPageKeys: ['planning_upload'],
};
}
function createFakeStore(clientOverrides = {}) {
let client = {
id: 1,
clientId: 'sso_test_client',
clientName: '测试应用',
clientType: 'confidential',
clientSecretHash: hashValue('client-secret'),
redirectUris: ['https://partner.example.com/oauth/callback'],
allowedScopes: ['profile', 'email', 'department', 'permissions'],
status: 'active',
...clientOverrides,
};
const codes = new Map();
const revoked = new Set();
const audits = [];
return {
client,
codes,
revoked,
audits,
async findClient(clientId) {
return client.clientId === clientId ? client : null;
},
async listClients() {
return [client];
},
async createClient(record) {
client = { id: 2, status: 'active', ...record };
this.client = client;
return client;
},
async updateClient(clientId, changes) {
if (client.clientId !== clientId) return null;
client = { ...client, ...changes };
this.client = client;
return client;
},
async insertAuthorizationCode(record) {
codes.set(record.codeHash, { ...record, consumedAt: null });
},
async findAuthorizationCode(codeHash, clientId) {
const record = codes.get(codeHash);
return record?.clientId === clientId ? record : null;
},
async consumeAuthorizationCode(codeHash) {
const record = codes.get(codeHash);
if (!record || record.consumedAt) return 0;
record.consumedAt = new Date();
return 1;
},
async touchClient() {},
async isTokenRevoked(jti) {
return revoked.has(jti);
},
async revokeToken(record) {
revoked.add(record.jti);
},
async audit(record) {
audits.push(record);
},
async cleanupExpired() {},
};
}
async function issueToken(store, overrides = {}) {
const verifier = overrides.verifier || 'A'.repeat(64);
const authorization = await validateAuthorizationRequest({
response_type: 'code',
client_id: store.client.clientId,
redirect_uri: store.client.redirectUris[0],
state: 'partner-state',
scope: 'profile email department permissions',
code_challenge: buildCodeChallenge(verifier),
code_challenge_method: 'S256',
}, { store });
assert.equal(authorization.stateClaims.flow, THIRD_PARTY_FLOW);
const completed = await completeThirdPartyAuthorization({
stateClaims: authorization.stateClaims,
userId: 42,
}, { store });
const code = new URL(completed.redirectUrl).searchParams.get('code');
const token = await exchangeAuthorizationCode({
grant_type: 'authorization_code',
code,
redirect_uri: store.client.redirectUris[0],
code_verifier: verifier,
}, {
clientId: store.client.clientId,
clientSecret: overrides.clientSecret === undefined ? 'client-secret' : overrides.clientSecret,
}, {
store,
resolveUser: async () => resolvedUser(),
});
return { code, token };
}
test.after(() => {
if (ORIGINAL_SECRET === undefined) delete process.env.CUBEJS_SECRET;
else process.env.CUBEJS_SECRET = ORIGINAL_SECRET;
});
test('third-party SSO callback stays on the configured issuer origin', () => {
assert.equal(getThirdPartySsoCallbackBaseUrl(), 'https://pddg.jiansuntuzhi.cn');
});
test('third-party SSO completes authorization-code + PKCE and returns scoped user info', async () => {
const store = createFakeStore();
const { token } = await issueToken(store);
assert.equal(token.token_type, 'Bearer');
assert.equal(token.scope, 'profile email department permissions');
const verified = await verifyThirdPartyAccessToken(token.access_token, {
store,
resolveUser: async () => resolvedUser(),
});
const claims = jwt.decode(token.access_token);
assert.equal(claims.tokenType, 'third-party-sso');
assert.equal(claims.aud, store.client.clientId);
assert.deepEqual(buildUserInfo(verified), {
sub: '42',
username: 'operator',
name: '陆南亭',
picture: '',
auth_source: 'dingtalk',
email: 'operator@example.com',
department: { id: 8, name: '运营中心' },
permissions: { role: 'user', allowed_page_keys: ['planning_upload'] },
});
});
test('authorization code is one-time and rejects replay', async () => {
const store = createFakeStore();
const { code } = await issueToken(store);
await assert.rejects(
exchangeAuthorizationCode({
grant_type: 'authorization_code',
code,
redirect_uri: store.client.redirectUris[0],
code_verifier: 'A'.repeat(64),
}, {
clientId: store.client.clientId,
clientSecret: 'client-secret',
}, { store, resolveUser: async () => resolvedUser() }),
(error) => error.code === 'invalid_grant'
);
});
test('authorization request rejects unregistered redirect URI and unsupported scopes', async () => {
const store = createFakeStore();
const base = {
response_type: 'code',
client_id: store.client.clientId,
state: 'partner-state',
code_challenge: buildCodeChallenge('B'.repeat(64)),
code_challenge_method: 'S256',
};
await assert.rejects(
validateAuthorizationRequest({
...base,
redirect_uri: 'https://evil.example.com/callback',
}, { store }),
/redirect_uri/
);
await assert.rejects(
validateAuthorizationRequest({
...base,
redirect_uri: store.client.redirectUris[0],
scope: 'profile admin',
}, { store }),
(error) => error.code === 'invalid_scope'
);
});
test('public clients use PKCE without a client secret', async () => {
const store = createFakeStore({
clientType: 'public',
clientSecretHash: '',
});
const { token } = await issueToken(store, { clientSecret: '' });
assert.ok(token.access_token);
});
test('revoked token cannot access userinfo', async () => {
const store = createFakeStore();
const { token } = await issueToken(store);
await revokeAccessToken(token.access_token, {
clientId: store.client.clientId,
clientSecret: 'client-secret',
}, { store });
await assert.rejects(
verifyThirdPartyAccessToken(token.access_token, {
store,
resolveUser: async () => resolvedUser(),
}),
(error) => error.code === 'invalid_token'
);
});
test('internal application JWT is rejected by third-party SSO verification', async () => {
const store = createFakeStore();
const internalToken = jwt.sign({
sub: '42',
clientId: store.client.clientId,
}, process.env.CUBEJS_SECRET, { expiresIn: '1h' });
await assert.rejects(
verifyThirdPartyAccessToken(internalToken, {
store,
resolveUser: async () => resolvedUser(),
}),
(error) => error.code === 'invalid_token'
);
});
test('confidential client creation returns its secret once and rotation invalidates the old hash', async () => {
const store = createFakeStore();
const created = await createSsoClient({
clientName: '新建第三方应用',
clientType: 'confidential',
redirectUris: ['https://new-partner.example.com/callback'],
allowedScopes: ['profile', 'department'],
}, { sub: 1 }, { store });
assert.ok(created.clientSecret);
assert.equal(store.client.clientSecretHash, hashValue(created.clientSecret));
assert.equal(created.clientSecretHash, undefined);
const oldHash = store.client.clientSecretHash;
const rotated = await rotateSsoClientSecret(created.clientId, { sub: 1 }, { store });
assert.ok(rotated.clientSecret);
assert.notEqual(store.client.clientSecretHash, oldHash);
assert.equal(store.client.clientSecretHash, hashValue(rotated.clientSecret));
});