forked from lunanting/cangku
271 lines
8.3 KiB
JavaScript
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));
|
|
});
|