0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2024-12-30 20:33:54 -05:00

refactor(test): add integration tests for org token grant

This commit is contained in:
Gao Sun 2023-11-09 16:57:33 +08:00
parent e057e2fc42
commit 52766acaa9
No known key found for this signature in database
GPG key ID: 13EBE123E4773688
7 changed files with 390 additions and 7 deletions

View file

@ -3,7 +3,7 @@ const config = {
transform: {}, transform: {},
testPathIgnorePatterns: ['/node_modules/'], testPathIgnorePatterns: ['/node_modules/'],
coverageProvider: 'v8', coverageProvider: 'v8',
setupFilesAfterEnv: ['./jest.setup.js', './jest.setup.api.js'], setupFilesAfterEnv: ['jest-matcher-specific-error', './jest.setup.js', './jest.setup.api.js'],
roots: ['./lib'], roots: ['./lib'],
moduleNameMapper: { moduleNameMapper: {
'^#src/(.*)\\.js(x)?$': '<rootDir>/lib/$1', '^#src/(.*)\\.js(x)?$': '<rootDir>/lib/$1',

View file

@ -2,7 +2,7 @@
const config = { const config = {
transform: {}, transform: {},
preset: 'jest-puppeteer', preset: 'jest-puppeteer',
setupFilesAfterEnv: ['./jest.setup.js'], setupFilesAfterEnv: ['jest-matcher-specific-error', './jest.setup.js'],
moduleNameMapper: { moduleNameMapper: {
'^#src/(.*)\\.js(x)?$': '<rootDir>/lib/$1', '^#src/(.*)\\.js(x)?$': '<rootDir>/lib/$1',
'^(chalk|inquirer)$': '<rootDir>/../shared/lib/esm/module-proxy.js', '^(chalk|inquirer)$': '<rootDir>/../shared/lib/esm/module-proxy.js',

View file

@ -38,7 +38,9 @@
"expect-puppeteer": "^9.0.0", "expect-puppeteer": "^9.0.0",
"got": "^13.0.0", "got": "^13.0.0",
"jest": "^29.5.0", "jest": "^29.5.0",
"jest-matcher-specific-error": "^1.0.0",
"jest-puppeteer": "^9.0.0", "jest-puppeteer": "^9.0.0",
"jose": "^5.0.0",
"node-fetch": "^3.3.0", "node-fetch": "^3.3.0",
"openapi-schema-validator": "^12.0.0", "openapi-schema-validator": "^12.0.0",
"openapi-types": "^12.0.0", "openapi-types": "^12.0.0",

View file

@ -18,12 +18,11 @@ export const defaultConfig = {
}; };
export default class MockClient { export default class MockClient {
public rawCookies: string[] = []; public rawCookies: string[] = [];
protected readonly config: LogtoConfig; protected readonly config: LogtoConfig;
protected readonly storage: MemoryStorage;
protected readonly logto: LogtoClient;
private navigateUrl?: string; private navigateUrl?: string;
private readonly storage: MemoryStorage;
private readonly logto: LogtoClient;
private readonly api: Got; private readonly api: Got;
constructor(config?: Partial<LogtoConfig>) { constructor(config?: Partial<LogtoConfig>) {

View file

@ -0,0 +1,377 @@
import assert from 'node:assert';
import { decodeAccessToken } from '@logto/js';
import { type LogtoConfig, Prompt } from '@logto/node';
import { GrantType, InteractionEvent, demoAppApplicationId } from '@logto/schemas';
import { isKeyInObject, removeUndefinedKeys } from '@silverhand/essentials';
import { HTTPError, got } from 'got';
import { createRemoteJWKSet, jwtVerify } from 'jose';
import { putInteraction } from '#src/api/index.js';
import MockClient, { defaultConfig } from '#src/client/index.js';
import { demoAppRedirectUri } from '#src/constants.js';
import { processSession } from '#src/helpers/client.js';
import { OrganizationApiTest } from '#src/helpers/organization.js';
import { enableAllPasswordSignInMethods } from '#src/helpers/sign-in-experience.js';
import { UserApiTest } from '#src/helpers/user.js';
import { generateUsername, generatePassword } from '#src/utils.js';
/** A helper class to simplify the test on grant errors. */
class GrantError extends Error {
constructor(
public readonly statusCode: number,
public readonly body: unknown
) {
super();
}
}
/** Create a grant error matcher that matches certain elements of the error response. */
const grantErrorContaining = (code: string, description: string, statusCode = 400) =>
new GrantError(
statusCode,
expect.objectContaining({
code,
error_description: description,
})
);
const accessDeniedError = grantErrorContaining(
'oidc.access_denied',
'user is not a member of the organization',
400
);
const issuer = defaultConfig.endpoint + '/oidc';
class MockOrganizationClient extends MockClient {
/** Perform the organization token grant. It may be replaced once our SDK supports it. */
async fetchOrganizationToken(organizationId?: string) {
const refreshToken = await this.getRefreshToken();
try {
const json = await got
.post(`${this.config.endpoint}/oidc/token`, {
form: removeUndefinedKeys({
grant_type: GrantType.OrganizationToken,
client_id: this.config.appId,
refresh_token: refreshToken,
organization_id: organizationId,
}),
})
.json();
if (isKeyInObject(json, 'refresh_token')) {
await this.storage.setItem('refreshToken', String(json.refresh_token));
}
return json;
} catch (error) {
if (error instanceof HTTPError) {
throw new GrantError(error.response.statusCode, JSON.parse(String(error.response.body)));
}
throw error;
}
}
}
/** An edited version that asserts the value is a record instead of an object */
const isObject = (value: unknown): value is Record<string, unknown> =>
value !== null && typeof value === 'object';
describe('OIDC organization token grant', () => {
const organizationApi = new OrganizationApiTest();
const userApi = new UserApiTest();
const username = generateUsername();
const password = generatePassword();
// eslint-disable-next-line @silverhand/fp/no-let
let userId = '';
const initClient = async (configOverrides?: Partial<LogtoConfig>) => {
const client = new MockOrganizationClient({
appId: demoAppApplicationId,
prompt: Prompt.Consent,
scopes: ['urn:logto:scope:organizations'],
resources: ['urn:logto:resource:organizations'],
...configOverrides,
});
await client.initSession(demoAppRedirectUri);
await client.successSend(putInteraction, {
event: InteractionEvent.SignIn,
identifier: { username, password },
});
const { redirectTo } = await client.submitInteraction();
await processSession(client, redirectTo);
return client;
};
/**
* Expect the response of the organization token grant. It validates the response json
* and the access token payload. Note it does not validate the signature of the access token.
*
* @param response The response of the organization token grant (json object).
* @param expectation The expected values of the response and the access token payload.
*/
const expectGrantResponse = (
response: unknown,
expectation: {
organizationId: string;
scopes: string[];
}
) => {
const { scopes, organizationId } = expectation;
// Expect response
assert(isObject(response), new Error('response is not an object'));
expect(response).toMatchObject({
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
access_token: expect.any(String),
expires_in: 3600,
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
refresh_token: expect.any(String),
token_type: 'Bearer',
});
expect(response).not.toHaveProperty('id_token');
expect(String(response.scope).split(' ').filter(Boolean).slice().sort()).toStrictEqual(
scopes.slice().sort()
);
// Expect access token
const accessToken = decodeAccessToken(String(response.access_token));
expect(accessToken.jti).toEqual(expect.any(String));
expect(accessToken.aud).toBe(`urn:logto:organization:${organizationId}`);
expect(accessToken.sub).toBe(userId);
expect(accessToken.client_id).toBe(demoAppApplicationId);
expect(accessToken.iss).toBe(issuer);
};
/**
* Initialize the test environment with some pre-defined data. It covers almost all
* possible cases for a user. See the source code for details.
*/
const initOrganizations = async () => {
const [org1, org2, org3, org4] = await Promise.all([
organizationApi.create({ name: 'org1' }),
organizationApi.create({ name: 'org2' }),
organizationApi.create({ name: 'org3' }),
organizationApi.create({ name: 'org4' }),
]);
const { roleApi, scopeApi } = organizationApi;
await organizationApi.addUsers(org1.id, [userId]);
await organizationApi.addUsers(org2.id, [userId]);
await organizationApi.addUsers(org3.id, [userId]);
const [scope1, scope2, scope3] = await Promise.all([
scopeApi.create({ name: 'scope1' }),
scopeApi.create({ name: 'scope2' }),
scopeApi.create({ name: 'scope3' }),
]);
const [role1, role2, role3, role4] = await Promise.all([
roleApi.create({ name: 'role1' }),
roleApi.create({ name: 'role2' }),
roleApi.create({ name: 'role3' }),
roleApi.create({ name: 'role4' }),
]);
await Promise.all([
roleApi.addScopes(role1.id, [scope1.id, scope2.id]),
roleApi.addScopes(role2.id, [scope2.id, scope3.id]),
roleApi.addScopes(role3.id, [scope1.id, scope3.id]),
]);
await Promise.all([
organizationApi.addUserRoles(org1.id, userId, [role1.id]),
organizationApi.addUserRoles(org2.id, userId, [role1.id, role2.id]),
organizationApi.addUserRoles(org3.id, userId, [role4.id]),
]);
return Object.freeze({
orgs: [org1, org2, org3, org4],
roles: [role1, role2, role3, role4],
} as const);
};
beforeAll(async () => {
const { id } = await userApi.create({ username, password });
// eslint-disable-next-line @silverhand/fp/no-mutation
userId = id;
await enableAllPasswordSignInMethods();
});
afterAll(async () => {
await userApi.cleanUp();
});
describe('sanity checks', () => {
afterEach(async () => {
await Promise.all([
organizationApi.cleanUp(),
organizationApi.roleApi.cleanUp(),
organizationApi.scopeApi.cleanUp(),
]);
});
it('should return error when organization id is not provided', async () => {
const client = await initClient();
await expect(client.fetchOrganizationToken()).rejects.toMatchError(
grantErrorContaining('oidc.invalid_request', "missing required parameter 'organization_id'")
);
});
it('should return error when organizations scope is not requested', async () => {
const client = await initClient({ scopes: [] });
await expect(client.fetchOrganizationToken('1')).rejects.toMatchError(
grantErrorContaining('oidc.insufficient_scope', 'refresh token missing required scope', 403)
);
});
it('should return access denied when organization id is invalid', async () => {
const client = await initClient();
await expect(client.fetchOrganizationToken('1')).rejects.toMatchError(accessDeniedError);
});
it('should return access denied when organization exists but user is not a member, then issue organization token after user is added to the organization', async () => {
const org = await organizationApi.create({ name: 'org' });
const client = await initClient();
// Not a member yet
await expect(client.fetchOrganizationToken(org.id)).rejects.toMatchError(accessDeniedError);
// Add user to the organization
await organizationApi.addUsers(org.id, [userId]);
expectGrantResponse(await client.fetchOrganizationToken(org.id), {
organizationId: org.id,
scopes: [],
});
// Remove user from the organization
await organizationApi.deleteUser(org.id, userId);
await expect(client.fetchOrganizationToken(org.id)).rejects.toMatchError(accessDeniedError);
});
it('should not issue organization scopes when organization resource is not requested', async () => {
const { orgs } = await initOrganizations();
const client = await initClient({
scopes: ['urn:logto:scope:organizations', 'scope1', 'scope2'],
resources: [],
});
expectGrantResponse(await client.fetchOrganizationToken(orgs[0].id), {
organizationId: orgs[0].id,
scopes: [],
});
expectGrantResponse(await client.fetchOrganizationToken(orgs[1].id), {
organizationId: orgs[1].id,
scopes: [],
});
expectGrantResponse(await client.fetchOrganizationToken(orgs[2].id), {
organizationId: orgs[2].id,
scopes: [],
});
});
it('should issue a signed JWT', async () => {
const org = await organizationApi.create({ name: 'org' });
const client = await initClient();
await organizationApi.addUsers(org.id, [userId]);
const response = await client.fetchOrganizationToken(org.id);
const rawToken = isObject(response) && String(response.access_token);
assert(typeof rawToken === 'string', new TypeError('access_token is not a string'));
await jwtVerify(
rawToken,
createRemoteJWKSet(new URL(defaultConfig.endpoint + '/oidc/jwks')),
{ issuer }
);
});
});
describe('permission checks', () => {
// eslint-disable-next-line @silverhand/fp/no-let
let context: Awaited<ReturnType<typeof initOrganizations>>;
beforeAll(async () => {
// eslint-disable-next-line @silverhand/fp/no-mutation
context = await initOrganizations();
});
afterAll(async () => {
await Promise.all([
organizationApi.cleanUp(),
organizationApi.roleApi.cleanUp(),
organizationApi.scopeApi.cleanUp(),
]);
});
it("should issue organization token according to user's role in the organization", async () => {
const { orgs } = context;
const client = await initClient({
scopes: ['urn:logto:scope:organizations', 'scope1', 'scope2', 'scope3'],
});
expectGrantResponse(await client.fetchOrganizationToken(orgs[0].id), {
organizationId: orgs[0].id,
scopes: ['scope1', 'scope2'],
});
expectGrantResponse(await client.fetchOrganizationToken(orgs[1].id), {
organizationId: orgs[1].id,
scopes: ['scope1', 'scope2', 'scope3'],
});
expectGrantResponse(await client.fetchOrganizationToken(orgs[2].id), {
organizationId: orgs[2].id,
scopes: [],
});
});
it('should down-scope according to the refresh token', async () => {
const { orgs } = context;
const client = await initClient({
scopes: ['urn:logto:scope:organizations', 'scope1', 'scope2'],
});
expectGrantResponse(await client.fetchOrganizationToken(orgs[0].id), {
organizationId: orgs[0].id,
scopes: ['scope1', 'scope2'],
});
expectGrantResponse(await client.fetchOrganizationToken(orgs[1].id), {
organizationId: orgs[1].id,
scopes: ['scope1', 'scope2'],
});
expectGrantResponse(await client.fetchOrganizationToken(orgs[2].id), {
organizationId: orgs[2].id,
scopes: [],
});
});
it('should be able to dynamically update scopes', async () => {
const { orgs, roles } = context;
const client = await initClient({
scopes: ['urn:logto:scope:organizations', 'scope1', 'scope2', 'scope3'],
});
expectGrantResponse(await client.fetchOrganizationToken(orgs[0].id), {
organizationId: orgs[0].id,
scopes: ['scope1', 'scope2'],
});
expectGrantResponse(await client.fetchOrganizationToken(orgs[1].id), {
organizationId: orgs[1].id,
scopes: ['scope1', 'scope2', 'scope3'],
});
// Update scopes
await Promise.all([
organizationApi.addUserRoles(orgs[0].id, userId, [roles[2].id]),
organizationApi.deleteUserRole(orgs[1].id, userId, roles[0].id),
organizationApi.deleteUserRole(orgs[1].id, userId, roles[1].id),
]);
expectGrantResponse(await client.fetchOrganizationToken(orgs[0].id), {
organizationId: orgs[0].id,
scopes: ['scope1', 'scope2', 'scope3'],
});
expectGrantResponse(await client.fetchOrganizationToken(orgs[1].id), {
organizationId: orgs[1].id,
scopes: [],
});
});
});
});

View file

@ -11,7 +11,7 @@
"src/*" "src/*"
] ]
}, },
"types": ["jest", "jest-puppeteer", "expect-puppeteer"], "types": ["jest", "jest-matcher-specific-error", "jest-puppeteer", "expect-puppeteer"],
}, },
"include": ["src"] "include": ["src"]
} }

View file

@ -3765,9 +3765,15 @@ importers:
jest: jest:
specifier: ^29.5.0 specifier: ^29.5.0
version: 29.5.0(@types/node@18.11.18)(ts-node@10.9.1) version: 29.5.0(@types/node@18.11.18)(ts-node@10.9.1)
jest-matcher-specific-error:
specifier: ^1.0.0
version: 1.0.0
jest-puppeteer: jest-puppeteer:
specifier: ^9.0.0 specifier: ^9.0.0
version: 9.0.0(puppeteer@21.0.0) version: 9.0.0(puppeteer@21.0.0)
jose:
specifier: ^5.0.0
version: 5.0.1
node-fetch: node-fetch:
specifier: ^3.3.0 specifier: ^3.3.0
version: 3.3.0 version: 3.3.0
@ -15077,7 +15083,6 @@ packages:
/jose@5.0.1: /jose@5.0.1:
resolution: {integrity: sha512-gRVzy7s3RRdGbXmcTdlOswJOjhwPLx1ijIgAqLY6ktzFpOJxxYn4l0fC2vHaHHi4YBX/5FOL3aY+6W0cvQgpug==} resolution: {integrity: sha512-gRVzy7s3RRdGbXmcTdlOswJOjhwPLx1ijIgAqLY6ktzFpOJxxYn4l0fC2vHaHHi4YBX/5FOL3aY+6W0cvQgpug==}
dev: false
/js-base64@3.7.5: /js-base64@3.7.5:
resolution: {integrity: sha512-3MEt5DTINKqfScXKfJFrRbxkrnk2AxPWGBL/ycjz4dK8iqiSJ06UxD8jh8xuh6p10TX4t2+7FsBYVxxQbMg+qA==} resolution: {integrity: sha512-3MEt5DTINKqfScXKfJFrRbxkrnk2AxPWGBL/ycjz4dK8iqiSJ06UxD8jh8xuh6p10TX4t2+7FsBYVxxQbMg+qA==}