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:
parent
2b3e482882
commit
8b63652c8e
3 changed files with 248 additions and 16 deletions
|
@ -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',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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),
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
Loading…
Reference in a new issue