Skip to content

Commit

Permalink
feat(core): organization token for token exchange flow (#6106)
Browse files Browse the repository at this point in the history
* feat(core,schemas): token exchange grant

* feat(core): third-party applications are not allowed for token exchange

* feat(core,schemas): token exchange grant

* feat(core): organization token for token exchange flow
  • Loading branch information
wangsijie authored Jul 2, 2024
1 parent 2b3e482 commit 8b63652
Show file tree
Hide file tree
Showing 3 changed files with 248 additions and 16 deletions.
85 changes: 83 additions & 2 deletions packages/core/src/oidc/grants/token-exchange.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,15 +16,16 @@ const findSubjectToken = jest.fn();
const updateSubjectTokenById = jest.fn();
const findApplicationById = jest.fn().mockResolvedValue(mockApplication);

const mockTenant = new MockTenant(undefined, {
const mockQueries = {
subjectTokens: {
findSubjectToken,
updateSubjectTokenById,
},
applications: {
findApplicationById,
},
});
};
const mockTenant = new MockTenant(undefined, mockQueries);
const mockHandler = (tenant = mockTenant) => {
return buildHandler(tenant.envSet, tenant.queries);
};
Expand Down Expand Up @@ -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');
Expand Down Expand Up @@ -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',
});
});
});
});
78 changes: 67 additions & 11 deletions packages/core/src/oidc/grants/token-exchange.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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
Expand Down Expand Up @@ -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,
Expand All @@ -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(
Expand All @@ -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
Expand All @@ -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();
Expand Down
101 changes: 98 additions & 3 deletions packages/integration-tests/src/tests/api/oidc/token-exchange.test.ts
Original file line number Diff line number Diff line change
@@ -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;

Expand Down Expand Up @@ -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),
});
});
});
});

0 comments on commit 8b63652

Please sign in to comment.