Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(core): organization token for token exchange flow #6106

Merged
merged 4 commits into from
Jul 2, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 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 @@ -81,10 +86,41 @@
throw new InvalidGrant('refresh token invalid (referenced account not found)');
}

// TODO: (LOG-9501) Implement general security checks like dPop

Check warning on line 89 in packages/core/src/oidc/grants/token-exchange.ts

View workflow job for this annotation

GitHub Actions / ESLint Report Analysis

packages/core/src/oidc/grants/token-exchange.ts#L89

[no-warning-comments] Unexpected 'todo' comment: 'TODO: (LOG-9501) Implement general...'.
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 @@
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 @@
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

Check warning on line 180 in packages/core/src/oidc/grants/token-exchange.ts

View check run for this annotation

Codecov / codecov/patch

packages/core/src/oidc/grants/token-exchange.ts#L180

Added line #L180 was not covered by tests
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 @@ -128,13 +186,11 @@
.filter(Set.prototype.has.bind(accessToken.resourceServer.scopes))
.join(' ');
} else {
// TODO: (LOG-9166) Check claims and scopes

Check warning on line 189 in packages/core/src/oidc/grants/token-exchange.ts

View workflow job for this annotation

GitHub Actions / ESLint Report Analysis

packages/core/src/oidc/grants/token-exchange.ts#L189

[no-warning-comments] Unexpected 'todo' comment: 'TODO: (LOG-9166) Check claims and scopes'.
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),
});
});
});
});
Loading