0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2024-12-16 20:26:19 -05:00

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

* 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
This commit is contained in:
wangsijie 2024-07-02 13:54:42 +08:00 committed by GitHub
parent 2b3e482882
commit 8b63652c8e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 248 additions and 16 deletions

View file

@ -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',
});
});
});
});

View file

@ -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();

View file

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