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:
parent
e057e2fc42
commit
52766acaa9
7 changed files with 390 additions and 7 deletions
|
@ -3,7 +3,7 @@ const config = {
|
|||
transform: {},
|
||||
testPathIgnorePatterns: ['/node_modules/'],
|
||||
coverageProvider: 'v8',
|
||||
setupFilesAfterEnv: ['./jest.setup.js', './jest.setup.api.js'],
|
||||
setupFilesAfterEnv: ['jest-matcher-specific-error', './jest.setup.js', './jest.setup.api.js'],
|
||||
roots: ['./lib'],
|
||||
moduleNameMapper: {
|
||||
'^#src/(.*)\\.js(x)?$': '<rootDir>/lib/$1',
|
||||
|
|
|
@ -2,7 +2,7 @@
|
|||
const config = {
|
||||
transform: {},
|
||||
preset: 'jest-puppeteer',
|
||||
setupFilesAfterEnv: ['./jest.setup.js'],
|
||||
setupFilesAfterEnv: ['jest-matcher-specific-error', './jest.setup.js'],
|
||||
moduleNameMapper: {
|
||||
'^#src/(.*)\\.js(x)?$': '<rootDir>/lib/$1',
|
||||
'^(chalk|inquirer)$': '<rootDir>/../shared/lib/esm/module-proxy.js',
|
||||
|
|
|
@ -38,7 +38,9 @@
|
|||
"expect-puppeteer": "^9.0.0",
|
||||
"got": "^13.0.0",
|
||||
"jest": "^29.5.0",
|
||||
"jest-matcher-specific-error": "^1.0.0",
|
||||
"jest-puppeteer": "^9.0.0",
|
||||
"jose": "^5.0.0",
|
||||
"node-fetch": "^3.3.0",
|
||||
"openapi-schema-validator": "^12.0.0",
|
||||
"openapi-types": "^12.0.0",
|
||||
|
|
|
@ -18,12 +18,11 @@ export const defaultConfig = {
|
|||
};
|
||||
export default class MockClient {
|
||||
public rawCookies: string[] = [];
|
||||
|
||||
protected readonly config: LogtoConfig;
|
||||
protected readonly storage: MemoryStorage;
|
||||
protected readonly logto: LogtoClient;
|
||||
|
||||
private navigateUrl?: string;
|
||||
private readonly storage: MemoryStorage;
|
||||
private readonly logto: LogtoClient;
|
||||
private readonly api: Got;
|
||||
|
||||
constructor(config?: Partial<LogtoConfig>) {
|
||||
|
|
|
@ -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: [],
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
|
@ -11,7 +11,7 @@
|
|||
"src/*"
|
||||
]
|
||||
},
|
||||
"types": ["jest", "jest-puppeteer", "expect-puppeteer"],
|
||||
"types": ["jest", "jest-matcher-specific-error", "jest-puppeteer", "expect-puppeteer"],
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
|
|
|
@ -3765,9 +3765,15 @@ importers:
|
|||
jest:
|
||||
specifier: ^29.5.0
|
||||
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:
|
||||
specifier: ^9.0.0
|
||||
version: 9.0.0(puppeteer@21.0.0)
|
||||
jose:
|
||||
specifier: ^5.0.0
|
||||
version: 5.0.1
|
||||
node-fetch:
|
||||
specifier: ^3.3.0
|
||||
version: 3.3.0
|
||||
|
@ -15077,7 +15083,6 @@ packages:
|
|||
|
||||
/jose@5.0.1:
|
||||
resolution: {integrity: sha512-gRVzy7s3RRdGbXmcTdlOswJOjhwPLx1ijIgAqLY6ktzFpOJxxYn4l0fC2vHaHHi4YBX/5FOL3aY+6W0cvQgpug==}
|
||||
dev: false
|
||||
|
||||
/js-base64@3.7.5:
|
||||
resolution: {integrity: sha512-3MEt5DTINKqfScXKfJFrRbxkrnk2AxPWGBL/ycjz4dK8iqiSJ06UxD8jh8xuh6p10TX4t2+7FsBYVxxQbMg+qA==}
|
||||
|
|
Loading…
Reference in a new issue