forked from lunanting/cangku
203 lines
7.2 KiB
JavaScript
203 lines
7.2 KiB
JavaScript
'use strict';
|
|
|
|
const assert = require('node:assert/strict');
|
|
const test = require('node:test');
|
|
const {
|
|
beginDirectLogin,
|
|
completeDirectLogin,
|
|
createDirectApiKey,
|
|
createDirectLoginSession,
|
|
parseDirectApiKey,
|
|
pollDirectLoginSession,
|
|
updateDirectReturnUrls,
|
|
} = require('../src/services/thirdPartySsoDirectService');
|
|
const { hashValue } = require('../src/services/thirdPartySsoService');
|
|
|
|
function resolvedUser() {
|
|
return {
|
|
id: 42,
|
|
username: 'operator',
|
|
name: '陆南亭',
|
|
email: 'operator@example.com',
|
|
role: 'user',
|
|
status: 'active',
|
|
auth_source: 'dingtalk',
|
|
departmentId: 8,
|
|
departmentName: '运营中心',
|
|
allowedPageKeys: [],
|
|
};
|
|
}
|
|
|
|
function createStores() {
|
|
let client = null;
|
|
const sessions = new Map();
|
|
const clientStore = {
|
|
async findClient(clientId) {
|
|
return client?.clientId === clientId ? client : null;
|
|
},
|
|
async createClient(record) {
|
|
client = { id: 1, status: 'active', ...record };
|
|
return client;
|
|
},
|
|
async updateClient(clientId, changes) {
|
|
if (client?.clientId !== clientId) return null;
|
|
client = { ...client, ...changes };
|
|
return client;
|
|
},
|
|
async audit() {},
|
|
};
|
|
const directStore = {
|
|
sessions,
|
|
async insertDirectSession(record) {
|
|
sessions.set(record.sessionHash, {
|
|
...record,
|
|
status: 'pending',
|
|
userId: null,
|
|
});
|
|
},
|
|
async findDirectSession(sessionHash, clientId = '') {
|
|
const session = sessions.get(sessionHash);
|
|
if (!session || (clientId && session.clientId !== clientId)) return null;
|
|
return session;
|
|
},
|
|
async completeDirectSession(sessionHash, userId) {
|
|
const session = sessions.get(sessionHash);
|
|
if (!session || session.status !== 'pending' || session.expiresAt <= Date.now()) return 0;
|
|
session.status = 'completed';
|
|
session.userId = userId;
|
|
return 1;
|
|
},
|
|
async consumeDirectSession(sessionHash, clientId) {
|
|
const session = sessions.get(sessionHash);
|
|
if (!session || session.clientId !== clientId || session.status !== 'completed') return 0;
|
|
session.status = 'consumed';
|
|
return 1;
|
|
},
|
|
async cleanupExpiredDirectSessions() {},
|
|
};
|
|
return { clientStore, directStore };
|
|
}
|
|
|
|
async function createCredential(clientStore, overrides = {}) {
|
|
return createDirectApiKey({
|
|
clientName: '第三方直连应用',
|
|
allowedScopes: ['profile', 'email', 'department'],
|
|
...overrides,
|
|
}, { sub: 1 }, { store: clientStore });
|
|
}
|
|
|
|
test('direct API Key login completes without a redirect URI and returns user once', async () => {
|
|
const { clientStore, directStore } = createStores();
|
|
const credential = await createCredential(clientStore);
|
|
assert.match(credential.apiKey, /^ddak_sso_/u);
|
|
assert.equal(parseDirectApiKey(credential.apiKey).clientId, credential.clientId);
|
|
|
|
const created = await createDirectLoginSession({
|
|
scope: 'profile email department',
|
|
}, credential.apiKey, { store: clientStore, directStore });
|
|
assert.equal(created.status, 'pending');
|
|
assert.match(created.login_url, /\/api\/sso\/direct\/login\?request_id=/u);
|
|
|
|
const begun = await beginDirectLogin(created.request_id, { directStore });
|
|
await completeDirectLogin({
|
|
stateClaims: begun.stateClaims,
|
|
userId: 42,
|
|
}, { directStore });
|
|
|
|
const completed = await pollDirectLoginSession(created.request_id, credential.apiKey, {
|
|
store: clientStore,
|
|
directStore,
|
|
resolveUser: async () => resolvedUser(),
|
|
});
|
|
assert.equal(completed.status, 'completed');
|
|
assert.deepEqual(completed.user, {
|
|
sub: '42',
|
|
username: 'operator',
|
|
name: '陆南亭',
|
|
picture: '',
|
|
auth_source: 'dingtalk',
|
|
email: 'operator@example.com',
|
|
department: { id: 8, name: '运营中心' },
|
|
});
|
|
|
|
const consumed = await pollDirectLoginSession(created.request_id, credential.apiKey, {
|
|
store: clientStore,
|
|
directStore,
|
|
resolveUser: async () => resolvedUser(),
|
|
});
|
|
assert.equal(consumed.status, 'consumed');
|
|
});
|
|
|
|
test('direct login rejects a wrong API Key and expired request', async () => {
|
|
const { clientStore, directStore } = createStores();
|
|
const credential = await createCredential(clientStore);
|
|
await assert.rejects(
|
|
createDirectLoginSession({}, `${credential.apiKey}x`, { store: clientStore, directStore }),
|
|
(error) => error.code === 'invalid_client'
|
|
);
|
|
|
|
const created = await createDirectLoginSession({}, credential.apiKey, {
|
|
store: clientStore,
|
|
directStore,
|
|
});
|
|
directStore.sessions.get(hashValue(created.request_id)).expiresAt = Date.now() - 1;
|
|
await assert.rejects(
|
|
beginDirectLogin(created.request_id, { directStore }),
|
|
(error) => error.status === 410
|
|
);
|
|
});
|
|
|
|
test('direct login returns to an allowlisted third-party URL after DingTalk completes', async () => {
|
|
const { clientStore, directStore } = createStores();
|
|
const credential = await createCredential(clientStore);
|
|
const configured = await updateDirectReturnUrls(credential.clientId, {
|
|
allowedReturnUrls: ['https://partner.example.com/auth/complete'],
|
|
}, { sub: 1 }, { store: clientStore });
|
|
assert.deepEqual(configured.allowedReturnUrls, ['https://partner.example.com/auth/complete']);
|
|
|
|
const created = await createDirectLoginSession({
|
|
scope: 'profile',
|
|
return_url: 'https://partner.example.com/auth/complete',
|
|
}, credential.apiKey, { store: clientStore, directStore });
|
|
assert.equal(created.return_url, 'https://partner.example.com/auth/complete');
|
|
|
|
const begun = await beginDirectLogin(created.request_id, { directStore });
|
|
const completed = await completeDirectLogin({
|
|
stateClaims: begun.stateClaims,
|
|
userId: 42,
|
|
}, { directStore });
|
|
const redirect = new URL(completed.completionRedirect);
|
|
assert.equal(`${redirect.origin}${redirect.pathname}`, 'https://partner.example.com/auth/complete');
|
|
assert.equal(redirect.searchParams.get('sso_request_id'), created.request_id);
|
|
assert.equal(redirect.searchParams.get('sso_status'), 'completed');
|
|
|
|
await assert.rejects(
|
|
createDirectLoginSession({
|
|
return_url: 'https://evil.example.com/callback',
|
|
}, credential.apiKey, { store: clientStore, directStore }),
|
|
(error) => error.code === 'invalid_request' && /白名单/u.test(error.message)
|
|
);
|
|
});
|
|
|
|
test('hosted direct login supports an exactly allowlisted HTTP partner URL', async () => {
|
|
const { clientStore, directStore } = createStores();
|
|
const credential = await createCredential(clientStore);
|
|
const configured = await updateDirectReturnUrls(credential.clientId, {
|
|
allowedReturnUrls: ['http://47.98.122.179'],
|
|
}, { sub: 1 }, { store: clientStore });
|
|
assert.deepEqual(configured.allowedReturnUrls, ['http://47.98.122.179/']);
|
|
|
|
const created = await createDirectLoginSession({
|
|
return_url: 'http://47.98.122.179',
|
|
}, credential.apiKey, { store: clientStore, directStore });
|
|
const begun = await beginDirectLogin(created.request_id, { directStore });
|
|
const completed = await completeDirectLogin({
|
|
stateClaims: begun.stateClaims,
|
|
userId: 42,
|
|
}, { directStore });
|
|
const redirect = new URL(completed.completionRedirect);
|
|
assert.equal(redirect.origin, 'http://47.98.122.179');
|
|
assert.equal(redirect.searchParams.get('sso_request_id'), created.request_id);
|
|
assert.equal(redirect.searchParams.get('sso_status'), 'completed');
|
|
});
|