diff --git a/packages/integration-tests/jest.config.js b/packages/integration-tests/jest.config.js index 2c1f814ce..415853387 100644 --- a/packages/integration-tests/jest.config.js +++ b/packages/integration-tests/jest.config.js @@ -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)?$': '/lib/$1', diff --git a/packages/integration-tests/jest.config.ui.js b/packages/integration-tests/jest.config.ui.js index 822a9d961..7a84cadc6 100644 --- a/packages/integration-tests/jest.config.ui.js +++ b/packages/integration-tests/jest.config.ui.js @@ -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)?$': '/lib/$1', '^(chalk|inquirer)$': '/../shared/lib/esm/module-proxy.js', diff --git a/packages/integration-tests/package.json b/packages/integration-tests/package.json index c9d27a214..8f0381dbc 100644 --- a/packages/integration-tests/package.json +++ b/packages/integration-tests/package.json @@ -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", diff --git a/packages/integration-tests/src/client/index.ts b/packages/integration-tests/src/client/index.ts index 557839d5d..35edc220f 100644 --- a/packages/integration-tests/src/client/index.ts +++ b/packages/integration-tests/src/client/index.ts @@ -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) { diff --git a/packages/integration-tests/src/tests/api/oidc/organization-token-grant.test.ts b/packages/integration-tests/src/tests/api/oidc/organization-token-grant.test.ts new file mode 100644 index 000000000..c358cd2bc --- /dev/null +++ b/packages/integration-tests/src/tests/api/oidc/organization-token-grant.test.ts @@ -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 => + 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) => { + 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>; + + 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: [], + }); + }); + }); +}); diff --git a/packages/integration-tests/tsconfig.json b/packages/integration-tests/tsconfig.json index b5a1d63d6..78adcad43 100644 --- a/packages/integration-tests/tsconfig.json +++ b/packages/integration-tests/tsconfig.json @@ -11,7 +11,7 @@ "src/*" ] }, - "types": ["jest", "jest-puppeteer", "expect-puppeteer"], + "types": ["jest", "jest-matcher-specific-error", "jest-puppeteer", "expect-puppeteer"], }, "include": ["src"] } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0376bf154..4f4fce8d3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -3771,9 +3771,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 @@ -15147,7 +15153,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==}