mirror of
https://github.com/logto-io/logto.git
synced 2025-01-06 20:40:08 -05:00
refactor(core): refactor organizations in grants (#6208)
This commit is contained in:
parent
ba875b417c
commit
608349e8ea
6 changed files with 187 additions and 153 deletions
packages
core/src/oidc/grants
integration-tests/src/tests/api/oidc
|
@ -19,7 +19,6 @@
|
||||||
* The commit hash of the original file is `0c52469f08b0a4a1854d90a96546a3f7aa090e5e`.
|
* The commit hash of the original file is `0c52469f08b0a4a1854d90a96546a3f7aa090e5e`.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { buildOrganizationUrn } from '@logto/core-kit';
|
|
||||||
import { cond } from '@silverhand/essentials';
|
import { cond } from '@silverhand/essentials';
|
||||||
import type Provider from 'oidc-provider';
|
import type Provider from 'oidc-provider';
|
||||||
import { errors } from 'oidc-provider';
|
import { errors } from 'oidc-provider';
|
||||||
|
@ -30,9 +29,7 @@ import { type EnvSet } from '#src/env-set/index.js';
|
||||||
import type Queries from '#src/tenants/Queries.js';
|
import type Queries from '#src/tenants/Queries.js';
|
||||||
import assertThat from '#src/utils/assert-that.js';
|
import assertThat from '#src/utils/assert-that.js';
|
||||||
|
|
||||||
import { getSharedResourceServerData, reversedResourceAccessTokenTtl } from '../resource.js';
|
import { handleClientCertificate, handleDPoP, handleOrganizationToken } from './utils.js';
|
||||||
|
|
||||||
import { handleClientCertificate, handleDPoP } from './utils.js';
|
|
||||||
|
|
||||||
const { AccessDenied, InvalidClient, InvalidGrant, InvalidScope, InvalidTarget } = errors;
|
const { AccessDenied, InvalidClient, InvalidGrant, InvalidScope, InvalidTarget } = errors;
|
||||||
|
|
||||||
|
@ -130,26 +127,17 @@ export const buildHandler: (
|
||||||
// If it's present, the flow falls into the `checkResource` and `if (resourceServer)` block above.
|
// If it's present, the flow falls into the `checkResource` and `if (resourceServer)` block above.
|
||||||
if (organizationId && !resourceServer) {
|
if (organizationId && !resourceServer) {
|
||||||
/* === RFC 0006 === */
|
/* === RFC 0006 === */
|
||||||
const audience = buildOrganizationUrn(organizationId);
|
|
||||||
const availableScopes = await queries.organizations.relations.appsRoles
|
const availableScopes = await queries.organizations.relations.appsRoles
|
||||||
.getApplicationScopes(organizationId, client.clientId)
|
.getApplicationScopes(organizationId, client.clientId)
|
||||||
.then((scope) => scope.map(({ name }) => name));
|
.then((scope) => scope.map(({ name }) => name));
|
||||||
|
|
||||||
/** The intersection of the available scopes and the requested scopes. */
|
await handleOrganizationToken({
|
||||||
const issuedScopes = availableScopes.filter((scope) => scopes.includes(scope)).join(' ');
|
envSet,
|
||||||
|
availableScopes,
|
||||||
token.aud = audience;
|
accessToken: token,
|
||||||
// Note: the original implementation uses `new provider.ResourceServer` to create the resource
|
organizationId,
|
||||||
// server. But it's not available in the typings. The class is actually very simple and holds
|
scope: new Set(scopes),
|
||||||
// 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;
|
|
||||||
/* === End RFC 0006 === */
|
/* === End RFC 0006 === */
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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 () => {
|
it('should throw when refresh token has no grant id or the grant cannot be found', async () => {
|
||||||
const ctx = createOidcContext(validOidcContext);
|
const ctx = createOidcContext(validOidcContext);
|
||||||
const findRefreshToken = stubRefreshToken(ctx, {
|
const findRefreshToken = stubRefreshToken(ctx, {
|
||||||
|
@ -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 () => {
|
it('should not explode when everything looks fine', async () => {
|
||||||
const ctx = createPreparedContext();
|
const ctx = createPreparedContext();
|
||||||
const tenant = new MockTenant();
|
const tenant = new MockTenant();
|
||||||
|
|
|
@ -19,8 +19,8 @@
|
||||||
* The commit hash of the original file is `cf2069cbb31a6a855876e95157372d25dde2511c`.
|
* The commit hash of the original file is `cf2069cbb31a6a855876e95157372d25dde2511c`.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { UserScope, buildOrganizationUrn } from '@logto/core-kit';
|
import { UserScope } from '@logto/core-kit';
|
||||||
import { isKeyInObject, cond } from '@silverhand/essentials';
|
import { isKeyInObject } from '@silverhand/essentials';
|
||||||
import type Provider from 'oidc-provider';
|
import type Provider from 'oidc-provider';
|
||||||
import { errors } from 'oidc-provider';
|
import { errors } from 'oidc-provider';
|
||||||
import difference from 'oidc-provider/lib/helpers/_/difference.js';
|
import difference from 'oidc-provider/lib/helpers/_/difference.js';
|
||||||
|
@ -35,13 +35,11 @@ import type Queries from '#src/tenants/Queries.js';
|
||||||
import assertThat from '#src/utils/assert-that.js';
|
import assertThat from '#src/utils/assert-that.js';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
getSharedResourceServerData,
|
handleClientCertificate,
|
||||||
isThirdPartyApplication,
|
handleDPoP,
|
||||||
reversedResourceAccessTokenTtl,
|
handleOrganizationToken,
|
||||||
isOrganizationConsentedToApplication,
|
checkOrganizationAccess,
|
||||||
} from '../resource.js';
|
} from './utils.js';
|
||||||
|
|
||||||
import { handleClientCertificate, handleDPoP } from './utils.js';
|
|
||||||
|
|
||||||
const { InvalidClient, InvalidGrant, InvalidScope, InsufficientScope, AccessDenied } = errors;
|
const { InvalidClient, InvalidGrant, InvalidScope, InsufficientScope, AccessDenied } = errors;
|
||||||
|
|
||||||
|
@ -72,7 +70,7 @@ export const buildHandler: (
|
||||||
// eslint-disable-next-line complexity
|
// eslint-disable-next-line complexity
|
||||||
) => Parameters<Provider['registerGrantType']>[1] = (envSet, queries) => async (ctx, next) => {
|
) => Parameters<Provider['registerGrantType']>[1] = (envSet, queries) => async (ctx, next) => {
|
||||||
const { client, params, requestParamScopes, provider } = ctx.oidc;
|
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(params, new InvalidGrant('parameters must be available'));
|
||||||
assertThat(client, new InvalidClient('client must be available'));
|
assertThat(client, new InvalidClient('client must be available'));
|
||||||
|
@ -83,11 +81,7 @@ export const buildHandler: (
|
||||||
const {
|
const {
|
||||||
rotateRefreshToken,
|
rotateRefreshToken,
|
||||||
conformIdTokenClaims,
|
conformIdTokenClaims,
|
||||||
features: {
|
features: { userinfo, resourceIndicators },
|
||||||
mTLS: { getCertificate },
|
|
||||||
userinfo,
|
|
||||||
resourceIndicators,
|
|
||||||
},
|
|
||||||
} = providerInstance.configuration();
|
} = providerInstance.configuration();
|
||||||
|
|
||||||
// @gao: I believe the presence of the param is validated by required parameters of this grant.
|
// @gao: I believe the presence of the param is validated by required parameters of this grant.
|
||||||
|
@ -107,18 +101,6 @@ export const buildHandler: (
|
||||||
throw new InvalidGrant('refresh token is expired');
|
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) {
|
if (!refreshToken.grantId) {
|
||||||
throw new InvalidGrant('grantId not found');
|
throw new InvalidGrant('grantId not found');
|
||||||
}
|
}
|
||||||
|
@ -177,45 +159,14 @@ export const buildHandler: (
|
||||||
throw new InvalidGrant('refresh token already used');
|
throw new InvalidGrant('refresh token already used');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const { organizationId } = await checkOrganizationAccess(ctx, queries, account);
|
||||||
|
|
||||||
/* === RFC 0001 === */
|
/* === RFC 0001 === */
|
||||||
if (organizationId) {
|
if (
|
||||||
// Check membership
|
organizationId && // Validate if the refresh token has the required scope from RFC 0001.
|
||||||
if (
|
!refreshToken.scopes.has(UserScope.Organizations)
|
||||||
!(await queries.organizations.relations.users.exists({
|
) {
|
||||||
organizationId,
|
throw new InsufficientScope('refresh token missing required scope', UserScope.Organizations);
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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 === */
|
/* === End RFC 0001 === */
|
||||||
|
|
||||||
|
@ -281,27 +232,17 @@ export const buildHandler: (
|
||||||
// the logic is handled in `getResourceServerInfo` and `extraTokenClaims`, see the init file of oidc-provider.
|
// the logic is handled in `getResourceServerInfo` and `extraTokenClaims`, see the init file of oidc-provider.
|
||||||
if (organizationId && !params.resource) {
|
if (organizationId && !params.resource) {
|
||||||
/* === RFC 0001 === */
|
/* === RFC 0001 === */
|
||||||
const audience = buildOrganizationUrn(organizationId);
|
|
||||||
/** All available scopes for the user in the organization. */
|
/** All available scopes for the user in the organization. */
|
||||||
const availableScopes = await queries.organizations.relations.usersRoles
|
const availableScopes = await queries.organizations.relations.usersRoles
|
||||||
.getUserScopes(organizationId, account.accountId)
|
.getUserScopes(organizationId, account.accountId)
|
||||||
.then((scopes) => scopes.map(({ name }) => name));
|
.then((scopes) => scopes.map(({ name }) => name));
|
||||||
|
await handleOrganizationToken({
|
||||||
/** The intersection of the available scopes and the requested scopes. */
|
envSet,
|
||||||
const issuedScopes = availableScopes.filter((name) => scope.has(name)).join(' ');
|
availableScopes,
|
||||||
|
accessToken: at,
|
||||||
at.aud = audience;
|
organizationId,
|
||||||
// Note: the original implementation uses `new provider.ResourceServer` to create the resource
|
scope,
|
||||||
// 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;
|
|
||||||
/* === End RFC 0001 === */
|
/* === End RFC 0001 === */
|
||||||
} else {
|
} else {
|
||||||
const resource = await resolveResource(
|
const resource = await resolveResource(
|
||||||
|
|
|
@ -6,7 +6,7 @@
|
||||||
|
|
||||||
import { buildOrganizationUrn } from '@logto/core-kit';
|
import { buildOrganizationUrn } from '@logto/core-kit';
|
||||||
import { GrantType } from '@logto/schemas';
|
import { GrantType } from '@logto/schemas';
|
||||||
import { cond, trySafe } from '@silverhand/essentials';
|
import { trySafe } from '@silverhand/essentials';
|
||||||
import type Provider from 'oidc-provider';
|
import type Provider from 'oidc-provider';
|
||||||
import { errors } from 'oidc-provider';
|
import { errors } from 'oidc-provider';
|
||||||
import resolveResource from 'oidc-provider/lib/helpers/resolve_resource.js';
|
import resolveResource from 'oidc-provider/lib/helpers/resolve_resource.js';
|
||||||
|
@ -22,7 +22,7 @@ import {
|
||||||
getSharedResourceServerData,
|
getSharedResourceServerData,
|
||||||
reversedResourceAccessTokenTtl,
|
reversedResourceAccessTokenTtl,
|
||||||
} from '../../resource.js';
|
} from '../../resource.js';
|
||||||
import { handleClientCertificate, handleDPoP } from '../utils.js';
|
import { handleClientCertificate, handleDPoP, checkOrganizationAccess } from '../utils.js';
|
||||||
|
|
||||||
import { handleActorToken } from './actor-token.js';
|
import { handleActorToken } from './actor-token.js';
|
||||||
import { TokenExchangeTokenType, type TokenExchangeAct } from './types.js';
|
import { TokenExchangeTokenType, type TokenExchangeAct } from './types.js';
|
||||||
|
@ -96,36 +96,7 @@ export const buildHandler: (
|
||||||
|
|
||||||
ctx.oidc.entity('Account', account);
|
ctx.oidc.entity('Account', account);
|
||||||
|
|
||||||
/* === RFC 0001 === */
|
const { organizationId } = await checkOrganizationAccess(ctx, queries, account);
|
||||||
// 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({
|
const accessToken = new AccessToken({
|
||||||
accountId: account.accountId,
|
accountId: account.accountId,
|
||||||
|
|
|
@ -1,13 +1,24 @@
|
||||||
|
import { buildOrganizationUrn } from '@logto/core-kit';
|
||||||
|
import { cond } from '@silverhand/essentials';
|
||||||
import type Provider from 'oidc-provider';
|
import type Provider from 'oidc-provider';
|
||||||
import { errors, type KoaContextWithOIDC } from 'oidc-provider';
|
import { type Account, errors, type KoaContextWithOIDC } from 'oidc-provider';
|
||||||
import certificateThumbprint from 'oidc-provider/lib/helpers/certificate_thumbprint.js';
|
import certificateThumbprint from 'oidc-provider/lib/helpers/certificate_thumbprint.js';
|
||||||
import epochTime from 'oidc-provider/lib/helpers/epoch_time.js';
|
import epochTime from 'oidc-provider/lib/helpers/epoch_time.js';
|
||||||
import dpopValidate from 'oidc-provider/lib/helpers/validate_dpop.js';
|
import dpopValidate from 'oidc-provider/lib/helpers/validate_dpop.js';
|
||||||
import instance from 'oidc-provider/lib/helpers/weak_cache.js';
|
import instance from 'oidc-provider/lib/helpers/weak_cache.js';
|
||||||
|
|
||||||
|
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 assertThat from '#src/utils/assert-that.js';
|
||||||
|
|
||||||
const { InvalidGrant, InvalidClient } = errors;
|
import {
|
||||||
|
getSharedResourceServerData,
|
||||||
|
isOrganizationConsentedToApplication,
|
||||||
|
isThirdPartyApplication,
|
||||||
|
reversedResourceAccessTokenTtl,
|
||||||
|
} from '../resource.js';
|
||||||
|
|
||||||
|
const { InvalidGrant, InvalidClient, AccessDenied } = errors;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handle DPoP bound access tokens.
|
* Handle DPoP bound access tokens.
|
||||||
|
@ -79,3 +90,102 @@ export const handleClientCertificate = async (
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Implement access check for RFC 0001
|
||||||
|
*/
|
||||||
|
export const checkOrganizationAccess = async (
|
||||||
|
ctx: KoaContextWithOIDC,
|
||||||
|
queries: Queries,
|
||||||
|
account: Account
|
||||||
|
): Promise<{ organizationId?: string }> => {
|
||||||
|
const { client, params } = ctx.oidc;
|
||||||
|
|
||||||
|
assertThat(params, new InvalidGrant('parameters must be available'));
|
||||||
|
assertThat(client, new InvalidClient('client must be available'));
|
||||||
|
|
||||||
|
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');
|
||||||
|
// eslint-disable-next-line @silverhand/fp/no-mutation
|
||||||
|
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');
|
||||||
|
// eslint-disable-next-line @silverhand/fp/no-mutation
|
||||||
|
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');
|
||||||
|
// eslint-disable-next-line @silverhand/fp/no-mutation
|
||||||
|
error.statusCode = 403;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { organizationId };
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Implement organization token for RFC 0001
|
||||||
|
*/
|
||||||
|
export const handleOrganizationToken = async ({
|
||||||
|
envSet,
|
||||||
|
availableScopes,
|
||||||
|
accessToken: at,
|
||||||
|
organizationId,
|
||||||
|
scope,
|
||||||
|
}: {
|
||||||
|
envSet: EnvSet;
|
||||||
|
availableScopes: string[];
|
||||||
|
accessToken: InstanceType<Provider['AccessToken']> | InstanceType<Provider['ClientCredentials']>;
|
||||||
|
organizationId: string;
|
||||||
|
scope: Set<string>;
|
||||||
|
}): Promise<void> => {
|
||||||
|
/* eslint-disable @silverhand/fp/no-mutation */
|
||||||
|
const audience = buildOrganizationUrn(organizationId);
|
||||||
|
|
||||||
|
/** 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;
|
||||||
|
|
||||||
|
/* eslint-enable @silverhand/fp/no-mutation */
|
||||||
|
};
|
||||||
|
|
|
@ -242,13 +242,6 @@ describe('`refresh_token` grant (for organization tokens)', () => {
|
||||||
expect(response.access_token).not.toContain('.');
|
expect(response.access_token).not.toContain('.');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should return error when organizations scope is not requested', async () => {
|
|
||||||
const client = await initClient({ scopes: [] });
|
|
||||||
await expect(client.fetchOrganizationToken('1')).rejects.toMatchError(
|
|
||||||
grantErrorContaining('oidc.insufficient_scope', 'refresh token missing required scope', 403)
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return access denied when organization id is invalid', async () => {
|
it('should return access denied when organization id is invalid', async () => {
|
||||||
const client = await initClient();
|
const client = await initClient();
|
||||||
await expect(client.fetchOrganizationToken('1')).rejects.toMatchError(accessDeniedError);
|
await expect(client.fetchOrganizationToken('1')).rejects.toMatchError(accessDeniedError);
|
||||||
|
@ -273,6 +266,17 @@ describe('`refresh_token` grant (for organization tokens)', () => {
|
||||||
await expect(client.fetchOrganizationToken(org.id)).rejects.toMatchError(accessDeniedError);
|
await expect(client.fetchOrganizationToken(org.id)).rejects.toMatchError(accessDeniedError);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should return error when organizations scope is not requested', async () => {
|
||||||
|
const org = await organizationApi.create({ name: 'org' });
|
||||||
|
await organizationApi.addUsers(org.id, [userId]);
|
||||||
|
|
||||||
|
const client = await initClient({ scopes: [] });
|
||||||
|
await expect(client.fetchOrganizationToken(org.id)).rejects.toMatchError(
|
||||||
|
grantErrorContaining('oidc.insufficient_scope', 'refresh token missing required scope', 403)
|
||||||
|
);
|
||||||
|
await organizationApi.deleteUser(org.id, userId);
|
||||||
|
});
|
||||||
|
|
||||||
it('should issue organization scopes even organization resource is not requested (handled by SDK)', async () => {
|
it('should issue organization scopes even organization resource is not requested (handled by SDK)', async () => {
|
||||||
const { orgs } = await initOrganizations();
|
const { orgs } = await initOrganizations();
|
||||||
|
|
||||||
|
|
Loading…
Reference in a new issue