Skip to content

Commit

Permalink
refactor(core): refactor organizations in grants (#6208)
Browse files Browse the repository at this point in the history
  • Loading branch information
wangsijie authored Jul 12, 2024
1 parent ba875b4 commit 608349e
Show file tree
Hide file tree
Showing 6 changed files with 187 additions and 153 deletions.
28 changes: 8 additions & 20 deletions packages/core/src/oidc/grants/client-credentials.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,6 @@
* The commit hash of the original file is `0c52469f08b0a4a1854d90a96546a3f7aa090e5e`.
*/

import { buildOrganizationUrn } from '@logto/core-kit';
import { cond } from '@silverhand/essentials';
import type Provider from 'oidc-provider';
import { errors } from 'oidc-provider';
Expand All @@ -30,9 +29,7 @@ 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 { getSharedResourceServerData, reversedResourceAccessTokenTtl } from '../resource.js';

import { handleClientCertificate, handleDPoP } from './utils.js';
import { handleClientCertificate, handleDPoP, handleOrganizationToken } from './utils.js';

const { AccessDenied, InvalidClient, InvalidGrant, InvalidScope, InvalidTarget } = errors;

Expand Down Expand Up @@ -130,26 +127,17 @@ export const buildHandler: (
// If it's present, the flow falls into the `checkResource` and `if (resourceServer)` block above.
if (organizationId && !resourceServer) {
/* === RFC 0006 === */
const audience = buildOrganizationUrn(organizationId);
const availableScopes = await queries.organizations.relations.appsRoles
.getApplicationScopes(organizationId, client.clientId)
.then((scope) => scope.map(({ name }) => name));

/** The intersection of the available scopes and the requested scopes. */
const issuedScopes = availableScopes.filter((scope) => scopes.includes(scope)).join(' ');

token.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
token.resourceServer = {
...getSharedResourceServerData(envSet),
accessTokenTTL: reversedResourceAccessTokenTtl,
audience,
scope: availableScopes.join(' '),
};
token.scope = issuedScopes;
await handleOrganizationToken({
envSet,
availableScopes,
accessToken: token,
organizationId,
scope: new Set(scopes),
});
/* === End RFC 0006 === */
}

Expand Down
40 changes: 30 additions & 10 deletions packages/core/src/oidc/grants/refresh-token.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -191,16 +191,6 @@ describe('refresh token grant', () => {
);
});

it('should throw when refresh token has no organization scope', async () => {
const ctx = createOidcContext(validOidcContext);
stubRefreshToken(ctx, {
scopes: new Set(),
});
await expect(mockHandler()(ctx, noop)).rejects.toMatchError(
new errors.InsufficientScope('refresh token missing required scope', UserScope.Organizations)
);
});

it('should throw when refresh token has no grant id or the grant cannot be found', async () => {
const ctx = createOidcContext(validOidcContext);
const findRefreshToken = stubRefreshToken(ctx, {
Expand Down Expand Up @@ -311,6 +301,36 @@ describe('refresh token grant', () => {
);
});

it('should throw when refresh token has no organization scope', async () => {
const ctx = createOidcContext({
...validOidcContext,
params: {
...validOidcContext.params,
scope: '',
},
});
const tenant = new MockTenant();
stubRefreshToken(ctx, {
scopes: new Set(),
});
stubGrant(ctx);
Sinon.stub(tenant.queries.organizations.relations.users, 'exists').resolves(true);
Sinon.stub(tenant.queries.applications, 'findApplicationById').resolves(mockApplication);
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,
});

await expect(mockHandler(tenant)(ctx, noop)).rejects.toMatchError(
new errors.InsufficientScope('refresh token missing required scope', UserScope.Organizations)
);
});

it('should not explode when everything looks fine', async () => {
const ctx = createPreparedContext();
const tenant = new MockTenant();
Expand Down
105 changes: 23 additions & 82 deletions packages/core/src/oidc/grants/refresh-token.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,8 @@
* The commit hash of the original file is `cf2069cbb31a6a855876e95157372d25dde2511c`.
*/

import { UserScope, buildOrganizationUrn } from '@logto/core-kit';
import { isKeyInObject, cond } from '@silverhand/essentials';
import { UserScope } from '@logto/core-kit';
import { isKeyInObject } from '@silverhand/essentials';
import type Provider from 'oidc-provider';
import { errors } from 'oidc-provider';
import difference from 'oidc-provider/lib/helpers/_/difference.js';
Expand All @@ -35,13 +35,11 @@ import type Queries from '#src/tenants/Queries.js';
import assertThat from '#src/utils/assert-that.js';

import {
getSharedResourceServerData,
isThirdPartyApplication,
reversedResourceAccessTokenTtl,
isOrganizationConsentedToApplication,
} from '../resource.js';

import { handleClientCertificate, handleDPoP } from './utils.js';
handleClientCertificate,
handleDPoP,
handleOrganizationToken,
checkOrganizationAccess,
} from './utils.js';

const { InvalidClient, InvalidGrant, InvalidScope, InsufficientScope, AccessDenied } = errors;

Expand Down Expand Up @@ -72,7 +70,7 @@ export const buildHandler: (
// eslint-disable-next-line complexity
) => Parameters<Provider['registerGrantType']>[1] = (envSet, queries) => async (ctx, next) => {
const { client, params, requestParamScopes, provider } = ctx.oidc;
const { RefreshToken, Account, AccessToken, Grant, ReplayDetection, IdToken } = provider;
const { RefreshToken, Account, AccessToken, Grant, IdToken } = provider;

assertThat(params, new InvalidGrant('parameters must be available'));
assertThat(client, new InvalidClient('client must be available'));
Expand All @@ -83,11 +81,7 @@ export const buildHandler: (
const {
rotateRefreshToken,
conformIdTokenClaims,
features: {
mTLS: { getCertificate },
userinfo,
resourceIndicators,
},
features: { userinfo, resourceIndicators },
} = providerInstance.configuration();

// @gao: I believe the presence of the param is validated by required parameters of this grant.
Expand All @@ -107,18 +101,6 @@ export const buildHandler: (
throw new InvalidGrant('refresh token is expired');
}

/* === 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 && // Validate if the refresh token has the required scope from RFC 0001.
!refreshToken.scopes.has(UserScope.Organizations)
) {
throw new InsufficientScope('refresh token missing required scope', UserScope.Organizations);
}
/* === End RFC 0001 === */

if (!refreshToken.grantId) {
throw new InvalidGrant('grantId not found');
}
Expand Down Expand Up @@ -177,45 +159,14 @@ export const buildHandler: (
throw new InvalidGrant('refresh token already used');
}

/* === RFC 0001 === */
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 is granted (third-party application only) by the user
if (
(await isThirdPartyApplication(queries, client.clientId)) &&
!(await isOrganizationConsentedToApplication(
queries,
client.clientId,
account.accountId,
organizationId
))
) {
const error = new AccessDenied('organization access is not granted to the application');
error.statusCode = 403;
throw error;
}
const { organizationId } = await checkOrganizationAccess(ctx, queries, account);

// 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;
}
/* === RFC 0001 === */
if (
organizationId && // Validate if the refresh token has the required scope from RFC 0001.
!refreshToken.scopes.has(UserScope.Organizations)
) {
throw new InsufficientScope('refresh token missing required scope', UserScope.Organizations);
}
/* === End RFC 0001 === */

Expand Down Expand Up @@ -281,27 +232,17 @@ export const buildHandler: (
// the logic is handled in `getResourceServerInfo` and `extraTokenClaims`, see the init file of oidc-provider.
if (organizationId && !params.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(' ');

at.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
at.resourceServer = {
...getSharedResourceServerData(envSet),
accessTokenTTL: reversedResourceAccessTokenTtl,
audience,
scope: availableScopes.join(' '),
};
at.scope = issuedScopes;
await handleOrganizationToken({
envSet,
availableScopes,
accessToken: at,
organizationId,
scope,
});
/* === End RFC 0001 === */
} else {
const resource = await resolveResource(
Expand Down
35 changes: 3 additions & 32 deletions packages/core/src/oidc/grants/token-exchange/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

import { buildOrganizationUrn } from '@logto/core-kit';
import { GrantType } from '@logto/schemas';
import { cond, trySafe } from '@silverhand/essentials';
import { 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 @@ -22,7 +22,7 @@ import {
getSharedResourceServerData,
reversedResourceAccessTokenTtl,
} from '../../resource.js';
import { handleClientCertificate, handleDPoP } from '../utils.js';
import { handleClientCertificate, handleDPoP, checkOrganizationAccess } from '../utils.js';

import { handleActorToken } from './actor-token.js';
import { TokenExchangeTokenType, type TokenExchangeAct } from './types.js';
Expand Down Expand Up @@ -96,36 +96,7 @@ export const buildHandler: (

ctx.oidc.entity('Account', account);

/* === 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 { organizationId } = await checkOrganizationAccess(ctx, queries, account);

const accessToken = new AccessToken({
accountId: account.accountId,
Expand Down
Loading

0 comments on commit 608349e

Please sign in to comment.