diff --git a/packages/core/src/oidc/grants/token-exchange.test.ts b/packages/core/src/oidc/grants/token-exchange.test.ts index 31504d756..183d11fb3 100644 --- a/packages/core/src/oidc/grants/token-exchange.test.ts +++ b/packages/core/src/oidc/grants/token-exchange.test.ts @@ -16,7 +16,7 @@ const findSubjectToken = jest.fn(); const updateSubjectTokenById = jest.fn(); const findApplicationById = jest.fn().mockResolvedValue(mockApplication); -const mockTenant = new MockTenant(undefined, { +const mockQueries = { subjectTokens: { findSubjectToken, updateSubjectTokenById, @@ -24,7 +24,8 @@ const mockTenant = new MockTenant(undefined, { applications: { findApplicationById, }, -}); +}; +const mockTenant = new MockTenant(undefined, mockQueries); const mockHandler = (tenant = mockTenant) => { return buildHandler(tenant.envSet, tenant.queries); }; @@ -69,6 +70,21 @@ const createPreparedContext = () => { return ctx; }; +const createPreparedOrganizationContext = () => { + const ctx = createOidcContext({ + ...validOidcContext, + params: { ...validOidcContext.params, organization_id: 'some_org_id' }, + }); + return ctx; +}; + +const createAccessDeniedError = (message: string, statusCode: number) => { + const error = new errors.AccessDenied(message); + // eslint-disable-next-line @silverhand/fp/no-mutation + error.statusCode = statusCode; + return error; +}; + beforeAll(() => { // `oidc-provider` will warn for dev interactions Sinon.stub(console, 'warn'); @@ -160,4 +176,69 @@ describe('token exchange', () => { gty: 'urn:ietf:params:oauth:grant-type:token-exchange', }); }); + + describe('RFC 0001 organization token', () => { + it('should throw if the user is not a member of the organization', async () => { + const ctx = createPreparedOrganizationContext(); + findSubjectToken.mockResolvedValueOnce(validSubjectToken); + Sinon.stub(ctx.oidc.provider.Account, 'findAccount').resolves({ accountId }); + + const tenant = new MockTenant(undefined, mockQueries); + Sinon.stub(tenant.queries.organizations.relations.users, 'exists').resolves(false); + await expect(mockHandler(tenant)(ctx, noop)).rejects.toThrow( + createAccessDeniedError('user is not a member of the organization', 403) + ); + }); + + it('should throw if the organization requires MFA but the user has not configured it', async () => { + const ctx = createPreparedOrganizationContext(); + findSubjectToken.mockResolvedValueOnce(validSubjectToken); + Sinon.stub(ctx.oidc.provider.Account, 'findAccount').resolves({ accountId }); + + const tenant = new MockTenant(undefined, mockQueries); + Sinon.stub(tenant.queries.organizations.relations.users, 'exists').resolves(true); + Sinon.stub(tenant.queries.organizations, 'getMfaStatus').resolves({ + isMfaRequired: true, + hasMfaConfigured: false, + }); + await expect(mockHandler(tenant)(ctx, noop)).rejects.toThrow( + createAccessDeniedError('organization requires MFA but user has no MFA configured', 403) + ); + }); + + it('should not explode when everything looks fine', async () => { + const ctx = createPreparedOrganizationContext(); + findSubjectToken.mockResolvedValueOnce(validSubjectToken); + Sinon.stub(ctx.oidc.provider.Account, 'findAccount').resolves({ accountId }); + + const tenant = new MockTenant(undefined, mockQueries); + Sinon.stub(tenant.queries.organizations.relations.users, 'exists').resolves(true); + Sinon.stub(tenant.queries.organizations.relations.usersRoles, 'getUserScopes').resolves([ + { tenantId: 'default', id: 'foo', name: 'foo', description: 'foo' }, + { tenantId: 'default', id: 'bar', name: 'bar', description: 'bar' }, + { tenantId: 'default', id: 'baz', name: 'baz', description: 'baz' }, + ]); + Sinon.stub(tenant.queries.organizations, 'getMfaStatus').resolves({ + isMfaRequired: false, + hasMfaConfigured: false, + }); + + const entityStub = Sinon.stub(ctx.oidc, 'entity'); + const noopStub = Sinon.stub().resolves(); + + await expect(mockHandler(tenant)(ctx, noopStub)).resolves.toBeUndefined(); + expect(noopStub.callCount).toBe(1); + expect(updateSubjectTokenById).toHaveBeenCalled(); + + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const [key, value] = entityStub.lastCall.args; + expect(key).toBe('AccessToken'); + expect(value).toMatchObject({ + accountId, + clientId, + grantId: subjectTokenId, + aud: 'urn:logto:organization:some_org_id', + }); + }); + }); }); diff --git a/packages/core/src/oidc/grants/token-exchange.ts b/packages/core/src/oidc/grants/token-exchange.ts index 08edfc02e..03b5dbf7b 100644 --- a/packages/core/src/oidc/grants/token-exchange.ts +++ b/packages/core/src/oidc/grants/token-exchange.ts @@ -4,8 +4,9 @@ * @see {@link https://github.com/logto-io/rfcs | Logto RFCs} for more information about RFC 0005. */ +import { buildOrganizationUrn } from '@logto/core-kit'; import { GrantType } from '@logto/schemas'; -import { trySafe } from '@silverhand/essentials'; +import { cond, trySafe } from '@silverhand/essentials'; import type Provider from 'oidc-provider'; import { errors } from 'oidc-provider'; import resolveResource from 'oidc-provider/lib/helpers/resolve_resource.js'; @@ -16,9 +17,13 @@ import { type EnvSet } from '#src/env-set/index.js'; import type Queries from '#src/tenants/Queries.js'; import assertThat from '#src/utils/assert-that.js'; -import { isThirdPartyApplication } from '../resource.js'; +import { + isThirdPartyApplication, + getSharedResourceServerData, + reversedResourceAccessTokenTtl, +} from '../resource.js'; -const { InvalidClient, InvalidGrant } = errors; +const { InvalidClient, InvalidGrant, AccessDenied } = errors; /** * The valid parameters for the `urn:ietf:params:oauth:grant-type:token-exchange` grant type. Note the `resource` parameter is @@ -84,7 +89,38 @@ export const buildHandler: ( // TODO: (LOG-9501) Implement general security checks like dPop ctx.oidc.entity('Account', account); - // TODO: (LOG-9140) Check organization permissions + /* eslint-disable @silverhand/fp/no-mutation, @typescript-eslint/no-unsafe-assignment */ + + /* === RFC 0001 === */ + // The value type is `unknown`, which will swallow other type inferences. So we have to cast it + // to `Boolean` first. + const organizationId = cond(Boolean(params.organization_id) && String(params.organization_id)); + + if (organizationId) { + // Check membership + if ( + !(await queries.organizations.relations.users.exists({ + organizationId, + userId: account.accountId, + })) + ) { + const error = new AccessDenied('user is not a member of the organization'); + error.statusCode = 403; + throw error; + } + + // Check if the organization requires MFA and the user has MFA enabled + const { isMfaRequired, hasMfaConfigured } = await queries.organizations.getMfaStatus( + organizationId, + account.accountId + ); + if (isMfaRequired && !hasMfaConfigured) { + const error = new AccessDenied('organization requires MFA but user has no MFA configured'); + error.statusCode = 403; + throw error; + } + } + /* === End RFC 0001 === */ const accessToken = new AccessToken({ accountId: account.accountId, @@ -96,8 +132,6 @@ export const buildHandler: ( scope: undefined!, }); - /* eslint-disable @silverhand/fp/no-mutation */ - /** The scopes requested by the client. If not provided, use the scopes from the refresh token. */ const scope = requestParamScopes; const resource = await resolveResource( @@ -112,14 +146,38 @@ export const buildHandler: ( scope ); - if (resource) { + if (organizationId && !resource) { + /* === RFC 0001 === */ + const audience = buildOrganizationUrn(organizationId); + /** All available scopes for the user in the organization. */ + const availableScopes = await queries.organizations.relations.usersRoles + .getUserScopes(organizationId, account.accountId) + .then((scopes) => scopes.map(({ name }) => name)); + + /** The intersection of the available scopes and the requested scopes. */ + const issuedScopes = availableScopes.filter((name) => scope.has(name)).join(' '); + + accessToken.aud = audience; + // Note: the original implementation uses `new provider.ResourceServer` to create the resource + // server. But it's not available in the typings. The class is actually very simple and holds + // no provider-specific context. So we just create the object manually. + // See https://github.com/panva/node-oidc-provider/blob/cf2069cbb31a6a855876e95157372d25dde2511c/lib/helpers/resource_server.js + accessToken.resourceServer = { + ...getSharedResourceServerData(envSet), + accessTokenTTL: reversedResourceAccessTokenTtl, + audience, + scope: availableScopes.join(' '), + }; + accessToken.scope = issuedScopes; + /* === End RFC 0001 === */ + } else if (resource) { const resourceServerInfo = await resourceIndicators.getResourceServerInfo( ctx, resource, client ); // @ts-expect-error -- code from oidc-provider - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-call + // eslint-disable-next-line @typescript-eslint/no-unsafe-call accessToken.resourceServer = new provider.ResourceServer(resource, resourceServerInfo); // For access token scopes, there is no "grant" to check, // filter the scopes based on the resource server's scopes @@ -132,9 +190,7 @@ export const buildHandler: ( accessToken.claims = ctx.oidc.claims; accessToken.scope = Array.from(scope).join(' '); } - // TODO: (LOG-9140) Handle organization token - - /* eslint-enable @silverhand/fp/no-mutation */ + /* eslint-enable @silverhand/fp/no-mutation, @typescript-eslint/no-unsafe-assignment */ ctx.oidc.entity('AccessToken', accessToken); const accessTokenString = await accessToken.save(); diff --git a/packages/integration-tests/src/tests/api/oidc/token-exchange.test.ts b/packages/integration-tests/src/tests/api/oidc/token-exchange.test.ts index 5d3a83d3d..9dda3e0bd 100644 --- a/packages/integration-tests/src/tests/api/oidc/token-exchange.test.ts +++ b/packages/integration-tests/src/tests/api/oidc/token-exchange.test.ts @@ -1,12 +1,15 @@ -import { ApplicationType, GrantType } from '@logto/schemas'; +import { buildOrganizationUrn } from '@logto/core-kit'; +import { decodeAccessToken } from '@logto/js'; +import { ApplicationType, GrantType, MfaFactor } from '@logto/schemas'; import { formUrlEncodedHeaders } from '@logto/shared'; -import { deleteUser } from '#src/api/admin-user.js'; +import { createUserMfaVerification, deleteUser } from '#src/api/admin-user.js'; import { oidcApi } from '#src/api/api.js'; import { createApplication, deleteApplication } from '#src/api/application.js'; import { createSubjectToken } from '#src/api/subject-token.js'; import { createUserByAdmin } from '#src/helpers/index.js'; -import { devFeatureTest, generateName } from '#src/utils.js'; +import { OrganizationApiTest } from '#src/helpers/organization.js'; +import { devFeatureTest, getAccessTokenPayload, randomString, generateName } from '#src/utils.js'; const { describe, it } = devFeatureTest; @@ -135,4 +138,96 @@ describe('Token Exchange', () => { await deleteApplication(thirdPartyApplication.id); }); }); + + describe('get access token for organization', () => { + const scopeName = `read:${randomString()}`; + + /* eslint-disable @silverhand/fp/no-let */ + let testApiScopeId: string; + let testOrganizationId: string; + /* eslint-enable @silverhand/fp/no-let */ + + const organizationApi = new OrganizationApiTest(); + + /* eslint-disable @silverhand/fp/no-mutation */ + beforeAll(async () => { + const organization = await organizationApi.create({ name: 'org1' }); + testOrganizationId = organization.id; + await organizationApi.addUsers(testOrganizationId, [userId]); + + const scope = await organizationApi.scopeApi.create({ name: scopeName }); + testApiScopeId = scope.id; + + const role = await organizationApi.roleApi.create({ name: `role1:${randomString()}` }); + await organizationApi.roleApi.addScopes(role.id, [scope.id]); + await organizationApi.addUserRoles(testOrganizationId, userId, [role.id]); + }); + /* eslint-enable @silverhand/fp/no-mutation */ + + afterAll(async () => { + await organizationApi.cleanUp(); + }); + + it('should be able to get access token for organization with correct scopes', async () => { + const { subjectToken } = await createSubjectToken(userId); + + const { access_token } = await oidcApi + .post('token', { + headers: formUrlEncodedHeaders, + body: new URLSearchParams({ + client_id: applicationId, + grant_type: GrantType.TokenExchange, + subject_token: subjectToken, + subject_token_type: 'urn:ietf:params:oauth:token-type:access_token', + organization_id: testOrganizationId, + scope: scopeName, + }), + }) + .json<{ access_token: string }>(); + + expect(getAccessTokenPayload(access_token)).toMatchObject({ + aud: buildOrganizationUrn(testOrganizationId), + scope: scopeName, + }); + }); + + it('should throw when organization requires mfa but user has not configured', async () => { + const { subjectToken } = await createSubjectToken(userId); + await organizationApi.update(testOrganizationId, { isMfaRequired: true }); + + await expect( + oidcApi.post('token', { + headers: formUrlEncodedHeaders, + body: new URLSearchParams({ + client_id: applicationId, + grant_type: GrantType.TokenExchange, + subject_token: subjectToken, + subject_token_type: 'urn:ietf:params:oauth:token-type:access_token', + organization_id: testOrganizationId, + }), + }) + ).rejects.toThrow(); + }); + + it('should be able to get access token for organization when user has mfa configured', async () => { + const { subjectToken } = await createSubjectToken(userId); + await createUserMfaVerification(userId, MfaFactor.TOTP); + const { access_token } = await oidcApi + .post('token', { + headers: formUrlEncodedHeaders, + body: new URLSearchParams({ + client_id: applicationId, + grant_type: GrantType.TokenExchange, + subject_token: subjectToken, + subject_token_type: 'urn:ietf:params:oauth:token-type:access_token', + organization_id: testOrganizationId, + }), + }) + .json<{ access_token: string }>(); + + expect(decodeAccessToken(access_token)).toMatchObject({ + aud: buildOrganizationUrn(testOrganizationId), + }); + }); + }); });