mirror of
https://github.com/logto-io/logto.git
synced 2024-12-30 20:33:54 -05:00
feat(core): handle access token with organization api resource (#5653)
This commit is contained in:
parent
e86ffa3a80
commit
d355ac7d20
10 changed files with 447 additions and 126 deletions
|
@ -88,6 +88,7 @@ export const createUserLibrary = (queries: Queries) => {
|
||||||
usersRoles: { findUsersRolesByRoleId, findUsersRolesByUserId },
|
usersRoles: { findUsersRolesByRoleId, findUsersRolesByUserId },
|
||||||
rolesScopes: { findRolesScopesByRoleIds },
|
rolesScopes: { findRolesScopesByRoleIds },
|
||||||
scopes: { findScopesByIdsAndResourceIndicator },
|
scopes: { findScopesByIdsAndResourceIndicator },
|
||||||
|
organizations,
|
||||||
} = queries;
|
} = queries;
|
||||||
|
|
||||||
const generateUserId = async (retries = 500) =>
|
const generateUserId = async (retries = 500) =>
|
||||||
|
@ -167,15 +168,25 @@ export const createUserLibrary = (queries: Queries) => {
|
||||||
return findUsersByIds(usersRoles.map(({ userId }) => userId));
|
return findUsersByIds(usersRoles.map(({ userId }) => userId));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find user scopes for a resource indicator, from roles and organization roles
|
||||||
|
* set organizationId to narrow down the search to the specific organization, otherwise it will search all organizations
|
||||||
|
*/
|
||||||
const findUserScopesForResourceIndicator = async (
|
const findUserScopesForResourceIndicator = async (
|
||||||
userId: string,
|
userId: string,
|
||||||
resourceIndicator: string
|
resourceIndicator: string,
|
||||||
|
organizationId?: string
|
||||||
): Promise<readonly Scope[]> => {
|
): Promise<readonly Scope[]> => {
|
||||||
const usersRoles = await findUsersRolesByUserId(userId);
|
const usersRoles = await findUsersRolesByUserId(userId);
|
||||||
const rolesScopes = await findRolesScopesByRoleIds(usersRoles.map(({ roleId }) => roleId));
|
const rolesScopes = await findRolesScopesByRoleIds(usersRoles.map(({ roleId }) => roleId));
|
||||||
|
const organizationScopes = await organizations.relations.rolesUsers.getUserResourceScopes(
|
||||||
|
userId,
|
||||||
|
resourceIndicator,
|
||||||
|
organizationId
|
||||||
|
);
|
||||||
|
|
||||||
const scopes = await findScopesByIdsAndResourceIndicator(
|
const scopes = await findScopesByIdsAndResourceIndicator(
|
||||||
rolesScopes.map(({ scopeId }) => scopeId),
|
[...rolesScopes.map(({ scopeId }) => scopeId), ...organizationScopes.map(({ id }) => id)],
|
||||||
resourceIndicator
|
resourceIndicator
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
167
packages/core/src/oidc/extra-token-claims.ts
Normal file
167
packages/core/src/oidc/extra-token-claims.ts
Normal file
|
@ -0,0 +1,167 @@
|
||||||
|
import {
|
||||||
|
type Json,
|
||||||
|
LogtoJwtTokenKey,
|
||||||
|
LogtoJwtTokenKeyType,
|
||||||
|
LogResult,
|
||||||
|
jwtCustomizer as jwtCustomizerLog,
|
||||||
|
} from '@logto/schemas';
|
||||||
|
import { generateStandardId } from '@logto/shared';
|
||||||
|
import { conditional, trySafe } from '@silverhand/essentials';
|
||||||
|
import { type KoaContextWithOIDC, type UnknownObject } from 'oidc-provider';
|
||||||
|
|
||||||
|
import { EnvSet } from '#src/env-set/index.js';
|
||||||
|
import { type CloudConnectionLibrary } from '#src/libraries/cloud-connection.js';
|
||||||
|
import { type LogtoConfigLibrary } from '#src/libraries/logto-config.js';
|
||||||
|
import { LogEntry } from '#src/middleware/koa-audit-log.js';
|
||||||
|
import type Libraries from '#src/tenants/Libraries.js';
|
||||||
|
import type Queries from '#src/tenants/Queries.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* For organization API resource feature,
|
||||||
|
* add extra token claim `organization_id` to the access token.
|
||||||
|
* notice that this is avaiable only when `resource` and `organization_id` are both present.
|
||||||
|
*/
|
||||||
|
export const getExtraTokenClaimsForOrganizationApiResource = async (
|
||||||
|
ctx: KoaContextWithOIDC,
|
||||||
|
token: unknown
|
||||||
|
): Promise<UnknownObject | undefined> => {
|
||||||
|
const { isDevFeaturesEnabled } = EnvSet.values;
|
||||||
|
|
||||||
|
if (!isDevFeaturesEnabled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const organizationId = ctx.oidc.params?.organization_id;
|
||||||
|
const resource = ctx.oidc.params?.resource;
|
||||||
|
|
||||||
|
if (!organizationId || !resource) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isAccessToken = token instanceof ctx.oidc.provider.AccessToken;
|
||||||
|
|
||||||
|
// Only handle access tokens
|
||||||
|
if (!isAccessToken) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { organization_id: organizationId };
|
||||||
|
};
|
||||||
|
|
||||||
|
/* eslint-disable complexity */
|
||||||
|
export const getExtraTokenClaimsForJwtCustomization = async (
|
||||||
|
ctx: KoaContextWithOIDC,
|
||||||
|
token: unknown,
|
||||||
|
{
|
||||||
|
envSet,
|
||||||
|
queries,
|
||||||
|
libraries,
|
||||||
|
logtoConfigs,
|
||||||
|
cloudConnection,
|
||||||
|
}: {
|
||||||
|
envSet: EnvSet;
|
||||||
|
queries: Queries;
|
||||||
|
libraries: Libraries;
|
||||||
|
logtoConfigs: LogtoConfigLibrary;
|
||||||
|
cloudConnection: CloudConnectionLibrary;
|
||||||
|
}
|
||||||
|
): Promise<UnknownObject | undefined> => {
|
||||||
|
const { isDevFeaturesEnabled, isCloud } = EnvSet.values;
|
||||||
|
// No cloud connection for OSS version, skip.
|
||||||
|
if (!isDevFeaturesEnabled || !isCloud) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Narrow down the token type to `AccessToken` and `ClientCredentials`.
|
||||||
|
if (
|
||||||
|
!(token instanceof ctx.oidc.provider.AccessToken) &&
|
||||||
|
!(token instanceof ctx.oidc.provider.ClientCredentials)
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isTokenClientCredentials = token instanceof ctx.oidc.provider.ClientCredentials;
|
||||||
|
|
||||||
|
try {
|
||||||
|
/**
|
||||||
|
* It is by design to use `trySafe` here to catch the error but not log it since we do not
|
||||||
|
* want to insert an error log every time the OIDC provider issues a token when the JWT
|
||||||
|
* customizer is not configured.
|
||||||
|
*/
|
||||||
|
const { script, environmentVariables } =
|
||||||
|
(await trySafe(
|
||||||
|
logtoConfigs.getJwtCustomizer(
|
||||||
|
isTokenClientCredentials
|
||||||
|
? LogtoJwtTokenKey.ClientCredentials
|
||||||
|
: LogtoJwtTokenKey.AccessToken
|
||||||
|
)
|
||||||
|
)) ?? {};
|
||||||
|
|
||||||
|
if (!script) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pickedFields = isTokenClientCredentials
|
||||||
|
? ctx.oidc.provider.ClientCredentials.IN_PAYLOAD
|
||||||
|
: ctx.oidc.provider.AccessToken.IN_PAYLOAD;
|
||||||
|
const readOnlyToken = Object.fromEntries(
|
||||||
|
pickedFields
|
||||||
|
.filter((field) => Reflect.get(token, field) !== undefined)
|
||||||
|
.map((field) => [field, Reflect.get(token, field)])
|
||||||
|
);
|
||||||
|
|
||||||
|
const client = await cloudConnection.getClient();
|
||||||
|
|
||||||
|
const commonPayload = {
|
||||||
|
script,
|
||||||
|
environmentVariables,
|
||||||
|
token: readOnlyToken,
|
||||||
|
};
|
||||||
|
|
||||||
|
// We pass context to the cloud API only when it is a user's access token.
|
||||||
|
const logtoUserInfo = conditional(
|
||||||
|
!isTokenClientCredentials &&
|
||||||
|
token.accountId &&
|
||||||
|
(await libraries.jwtCustomizers.getUserContext(token.accountId))
|
||||||
|
);
|
||||||
|
|
||||||
|
// `context` parameter is only eligible for user's access token for now.
|
||||||
|
return await client.post(`/api/services/custom-jwt`, {
|
||||||
|
body: isTokenClientCredentials
|
||||||
|
? {
|
||||||
|
...commonPayload,
|
||||||
|
tokenType: LogtoJwtTokenKeyType.ClientCredentials,
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
...commonPayload,
|
||||||
|
tokenType: LogtoJwtTokenKeyType.AccessToken,
|
||||||
|
// TODO (LOG-8555): the newly added `UserProfile` type includes undefined fields and can not be directly assigned to `Json` type. And the `undefined` fields should be removed by zod guard.
|
||||||
|
// eslint-disable-next-line no-restricted-syntax
|
||||||
|
context: { user: logtoUserInfo as Record<string, Json> },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error: unknown) {
|
||||||
|
const entry = new LogEntry(
|
||||||
|
`${jwtCustomizerLog.prefix}.${
|
||||||
|
isTokenClientCredentials
|
||||||
|
? jwtCustomizerLog.Type.ClientCredentials
|
||||||
|
: jwtCustomizerLog.Type.AccessToken
|
||||||
|
}`
|
||||||
|
);
|
||||||
|
entry.append({
|
||||||
|
result: LogResult.Error,
|
||||||
|
error: { message: String(error) },
|
||||||
|
});
|
||||||
|
const { payload } = entry;
|
||||||
|
await queries.logs.insertLog({
|
||||||
|
id: generateStandardId(),
|
||||||
|
key: payload.key,
|
||||||
|
payload: {
|
||||||
|
...payload,
|
||||||
|
tenantId: envSet.tenantId,
|
||||||
|
token,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
/* eslint-enable complexity */
|
|
@ -238,21 +238,6 @@ describe('organization token grant', () => {
|
||||||
await expect(mockHandler()(ctx, noop)).rejects.toThrow(errors.InvalidScope);
|
await expect(mockHandler()(ctx, noop)).rejects.toThrow(errors.InvalidScope);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should throw when both `resource` and `organization_id` are present in request', async () => {
|
|
||||||
const ctx = createOidcContext({
|
|
||||||
...validOidcContext,
|
|
||||||
params: {
|
|
||||||
...validOidcContext.params,
|
|
||||||
resource: 'some_resource',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
stubRefreshToken(ctx);
|
|
||||||
stubGrant(ctx);
|
|
||||||
await expect(mockHandler()(ctx, noop)).rejects.toMatchError(
|
|
||||||
new errors.InvalidRequest('resource is not allowed when requesting organization token')
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should throw when account cannot be found or account id mismatch', async () => {
|
it('should throw when account cannot be found or account id mismatch', async () => {
|
||||||
const ctx = createOidcContext(validOidcContext);
|
const ctx = createOidcContext(validOidcContext);
|
||||||
stubRefreshToken(ctx);
|
stubRefreshToken(ctx);
|
||||||
|
|
|
@ -33,7 +33,7 @@ import dpopValidate from 'oidc-provider/lib/helpers/validate_dpop.js';
|
||||||
import validatePresence from 'oidc-provider/lib/helpers/validate_presence.js';
|
import validatePresence from 'oidc-provider/lib/helpers/validate_presence.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 { 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';
|
||||||
|
|
||||||
|
@ -144,7 +144,7 @@ export const buildHandler: (
|
||||||
throw new InsufficientScope('refresh token missing required scope', UserScope.Organizations);
|
throw new InsufficientScope('refresh token missing required scope', UserScope.Organizations);
|
||||||
}
|
}
|
||||||
// Does not allow requesting resource token when requesting organization token (yet).
|
// Does not allow requesting resource token when requesting organization token (yet).
|
||||||
if (params.resource) {
|
if (!EnvSet.values.isDevFeaturesEnabled && params.resource) {
|
||||||
throw new InvalidRequest('resource is not allowed when requesting organization token');
|
throw new InvalidRequest('resource is not allowed when requesting organization token');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -313,8 +313,11 @@ export const buildHandler: (
|
||||||
/** The scopes requested by the client. If not provided, use the scopes from the refresh token. */
|
/** The scopes requested by the client. If not provided, use the scopes from the refresh token. */
|
||||||
const scope = params.scope ? requestParamScopes : refreshToken.scopes;
|
const scope = params.scope ? requestParamScopes : refreshToken.scopes;
|
||||||
|
|
||||||
/* === RFC 0001 === */
|
// Note, issue organization token only if `params.resource` is not present.
|
||||||
if (organizationId) {
|
// If resource is set, will issue normal access token with extra claim "organization_id",
|
||||||
|
// 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);
|
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.rolesUsers
|
const availableScopes = await queries.organizations.relations.rolesUsers
|
||||||
|
|
|
@ -13,26 +13,20 @@ import {
|
||||||
inSeconds,
|
inSeconds,
|
||||||
logtoCookieKey,
|
logtoCookieKey,
|
||||||
type LogtoUiCookie,
|
type LogtoUiCookie,
|
||||||
LogtoJwtTokenKey,
|
|
||||||
ExtraParamsKey,
|
ExtraParamsKey,
|
||||||
type Json,
|
|
||||||
jwtCustomizer as jwtCustomizerLog,
|
|
||||||
LogResult,
|
|
||||||
LogtoJwtTokenKeyType,
|
|
||||||
} from '@logto/schemas';
|
} from '@logto/schemas';
|
||||||
import { generateStandardId } from '@logto/shared';
|
|
||||||
import { conditional, trySafe, tryThat } from '@silverhand/essentials';
|
import { conditional, trySafe, tryThat } from '@silverhand/essentials';
|
||||||
import i18next from 'i18next';
|
import i18next from 'i18next';
|
||||||
import koaBody from 'koa-body';
|
import koaBody from 'koa-body';
|
||||||
import Provider, { errors } from 'oidc-provider';
|
import Provider, { errors } from 'oidc-provider';
|
||||||
import snakecaseKeys from 'snakecase-keys';
|
import snakecaseKeys from 'snakecase-keys';
|
||||||
|
|
||||||
import { EnvSet } from '#src/env-set/index.js';
|
import { type EnvSet } from '#src/env-set/index.js';
|
||||||
import RequestError from '#src/errors/RequestError/index.js';
|
import RequestError from '#src/errors/RequestError/index.js';
|
||||||
import { addOidcEventListeners } from '#src/event-listeners/index.js';
|
import { addOidcEventListeners } from '#src/event-listeners/index.js';
|
||||||
import { type CloudConnectionLibrary } from '#src/libraries/cloud-connection.js';
|
import { type CloudConnectionLibrary } from '#src/libraries/cloud-connection.js';
|
||||||
import { type LogtoConfigLibrary } from '#src/libraries/logto-config.js';
|
import { type LogtoConfigLibrary } from '#src/libraries/logto-config.js';
|
||||||
import koaAuditLog, { LogEntry } from '#src/middleware/koa-audit-log.js';
|
import koaAuditLog from '#src/middleware/koa-audit-log.js';
|
||||||
import koaBodyEtag from '#src/middleware/koa-body-etag.js';
|
import koaBodyEtag from '#src/middleware/koa-body-etag.js';
|
||||||
import postgresAdapter from '#src/oidc/adapter.js';
|
import postgresAdapter from '#src/oidc/adapter.js';
|
||||||
import {
|
import {
|
||||||
|
@ -44,6 +38,10 @@ import type Libraries from '#src/tenants/Libraries.js';
|
||||||
import type Queries from '#src/tenants/Queries.js';
|
import type Queries from '#src/tenants/Queries.js';
|
||||||
|
|
||||||
import defaults from './defaults.js';
|
import defaults from './defaults.js';
|
||||||
|
import {
|
||||||
|
getExtraTokenClaimsForJwtCustomization,
|
||||||
|
getExtraTokenClaimsForOrganizationApiResource,
|
||||||
|
} from './extra-token-claims.js';
|
||||||
import { registerGrants } from './grants/index.js';
|
import { registerGrants } from './grants/index.js';
|
||||||
import {
|
import {
|
||||||
findResource,
|
findResource,
|
||||||
|
@ -68,7 +66,6 @@ export default function initOidc(
|
||||||
resources: { findDefaultResource },
|
resources: { findDefaultResource },
|
||||||
users: { findUserById },
|
users: { findUserById },
|
||||||
organizations,
|
organizations,
|
||||||
logs: { insertLog },
|
|
||||||
} = queries;
|
} = queries;
|
||||||
const logoutSource = readFileSync('static/html/logout.html', 'utf8');
|
const logoutSource = readFileSync('static/html/logout.html', 'utf8');
|
||||||
const logoutSuccessSource = readFileSync('static/html/post-logout/index.html', 'utf8');
|
const logoutSuccessSource = readFileSync('static/html/post-logout/index.html', 'utf8');
|
||||||
|
@ -145,8 +142,22 @@ export default function initOidc(
|
||||||
|
|
||||||
const { accessTokenTtl: accessTokenTTL } = resourceServer;
|
const { accessTokenTtl: accessTokenTTL } = resourceServer;
|
||||||
|
|
||||||
const scopes = await findResourceScopes(queries, libraries, ctx, indicator);
|
const { client, params } = ctx.oidc;
|
||||||
const { client } = ctx.oidc;
|
/**
|
||||||
|
* In consent or code excange flow, the organization_id is undefined,
|
||||||
|
* and all the scopes inherited from the all organization roles will be granted.
|
||||||
|
* In the flow of granting token for organization with api resource,
|
||||||
|
* this value is set to the organization id,
|
||||||
|
* and will then narrow down the scopes to the specific organization.
|
||||||
|
*/
|
||||||
|
const organizationId = params?.organization_id;
|
||||||
|
const scopes = await findResourceScopes(
|
||||||
|
queries,
|
||||||
|
libraries,
|
||||||
|
ctx,
|
||||||
|
indicator,
|
||||||
|
typeof organizationId === 'string' ? organizationId : undefined
|
||||||
|
);
|
||||||
|
|
||||||
// Need to filter out the unsupported scopes for the third-party application.
|
// Need to filter out the unsupported scopes for the third-party application.
|
||||||
if (client && (await isThirdPartyApplication(queries, client.clientId))) {
|
if (client && (await isThirdPartyApplication(queries, client.clientId))) {
|
||||||
|
@ -210,98 +221,29 @@ export default function initOidc(
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
extraParams: Object.values(ExtraParamsKey),
|
extraParams: Object.values(ExtraParamsKey),
|
||||||
// eslint-disable-next-line complexity
|
|
||||||
extraTokenClaims: async (ctx, token) => {
|
|
||||||
const { isDevFeaturesEnabled, isCloud } = EnvSet.values;
|
|
||||||
|
|
||||||
// No cloud connection for OSS version, skip.
|
extraTokenClaims: async (ctx, token) => {
|
||||||
if (!isDevFeaturesEnabled || !isCloud) {
|
const organizationApiResourceClaims = await getExtraTokenClaimsForOrganizationApiResource(
|
||||||
|
ctx,
|
||||||
|
token
|
||||||
|
);
|
||||||
|
|
||||||
|
const jwtCustomizedClaims = await getExtraTokenClaimsForJwtCustomization(ctx, token, {
|
||||||
|
envSet,
|
||||||
|
queries,
|
||||||
|
libraries,
|
||||||
|
logtoConfigs,
|
||||||
|
cloudConnection,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!organizationApiResourceClaims && !jwtCustomizedClaims) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const isTokenClientCredentials = token instanceof ctx.oidc.provider.ClientCredentials;
|
return {
|
||||||
|
...organizationApiResourceClaims,
|
||||||
try {
|
...jwtCustomizedClaims,
|
||||||
/**
|
};
|
||||||
* It is by design to use `trySafe` here to catch the error but not log it since we do not
|
|
||||||
* want to insert an error log every time the OIDC provider issues a token when the JWT
|
|
||||||
* customizer is not configured.
|
|
||||||
*/
|
|
||||||
const { script, environmentVariables } =
|
|
||||||
(await trySafe(
|
|
||||||
logtoConfigs.getJwtCustomizer(
|
|
||||||
isTokenClientCredentials
|
|
||||||
? LogtoJwtTokenKey.ClientCredentials
|
|
||||||
: LogtoJwtTokenKey.AccessToken
|
|
||||||
)
|
|
||||||
)) ?? {};
|
|
||||||
|
|
||||||
if (!script) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const pickedFields = isTokenClientCredentials
|
|
||||||
? ctx.oidc.provider.ClientCredentials.IN_PAYLOAD
|
|
||||||
: ctx.oidc.provider.AccessToken.IN_PAYLOAD;
|
|
||||||
const readOnlyToken = Object.fromEntries(
|
|
||||||
pickedFields
|
|
||||||
.filter((field) => Reflect.get(token, field) !== undefined)
|
|
||||||
.map((field) => [field, Reflect.get(token, field)])
|
|
||||||
);
|
|
||||||
|
|
||||||
const client = await cloudConnection.getClient();
|
|
||||||
|
|
||||||
const commonPayload = {
|
|
||||||
script,
|
|
||||||
environmentVariables,
|
|
||||||
token: readOnlyToken,
|
|
||||||
};
|
|
||||||
|
|
||||||
// We pass context to the cloud API only when it is a user's access token.
|
|
||||||
const logtoUserInfo = conditional(
|
|
||||||
!isTokenClientCredentials &&
|
|
||||||
token.accountId &&
|
|
||||||
(await libraries.jwtCustomizers.getUserContext(token.accountId))
|
|
||||||
);
|
|
||||||
|
|
||||||
// `context` parameter is only eligible for user's access token for now.
|
|
||||||
return await client.post(`/api/services/custom-jwt`, {
|
|
||||||
body: isTokenClientCredentials
|
|
||||||
? {
|
|
||||||
...commonPayload,
|
|
||||||
tokenType: LogtoJwtTokenKeyType.ClientCredentials,
|
|
||||||
}
|
|
||||||
: {
|
|
||||||
...commonPayload,
|
|
||||||
tokenType: LogtoJwtTokenKeyType.AccessToken,
|
|
||||||
// TODO (LOG-8555): the newly added `UserProfile` type includes undefined fields and can not be directly assigned to `Json` type. And the `undefined` fields should be removed by zod guard.
|
|
||||||
// eslint-disable-next-line no-restricted-syntax
|
|
||||||
context: { user: logtoUserInfo as Record<string, Json> },
|
|
||||||
},
|
|
||||||
});
|
|
||||||
} catch (error: unknown) {
|
|
||||||
const entry = new LogEntry(
|
|
||||||
`${jwtCustomizerLog.prefix}.${
|
|
||||||
isTokenClientCredentials
|
|
||||||
? jwtCustomizerLog.Type.ClientCredentials
|
|
||||||
: jwtCustomizerLog.Type.AccessToken
|
|
||||||
}`
|
|
||||||
);
|
|
||||||
entry.append({
|
|
||||||
result: LogResult.Error,
|
|
||||||
error: { message: String(error) },
|
|
||||||
});
|
|
||||||
const { payload } = entry;
|
|
||||||
await insertLog({
|
|
||||||
id: generateStandardId(),
|
|
||||||
key: payload.key,
|
|
||||||
payload: {
|
|
||||||
...payload,
|
|
||||||
tenantId: envSet.tenantId,
|
|
||||||
token,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
extraClientMetadata: {
|
extraClientMetadata: {
|
||||||
properties: Object.values(CustomClientMetadataKey),
|
properties: Object.values(CustomClientMetadataKey),
|
||||||
|
|
|
@ -33,7 +33,8 @@ export const findResourceScopes = async (
|
||||||
queries: Queries,
|
queries: Queries,
|
||||||
libraries: Libraries,
|
libraries: Libraries,
|
||||||
ctx: KoaContextWithOIDC,
|
ctx: KoaContextWithOIDC,
|
||||||
indicator: string
|
indicator: string,
|
||||||
|
organizationId?: string
|
||||||
): Promise<ReadonlyArray<{ name: string; id: string }>> => {
|
): Promise<ReadonlyArray<{ name: string; id: string }>> => {
|
||||||
if (isReservedResource(indicator)) {
|
if (isReservedResource(indicator)) {
|
||||||
switch (indicator) {
|
switch (indicator) {
|
||||||
|
@ -56,7 +57,7 @@ export const findResourceScopes = async (
|
||||||
const userId = oidc.session?.accountId ?? oidc.entities.Account?.accountId;
|
const userId = oidc.session?.accountId ?? oidc.entities.Account?.accountId;
|
||||||
|
|
||||||
if (userId) {
|
if (userId) {
|
||||||
return findUserScopesForResourceIndicator(userId, indicator);
|
return findUserScopesForResourceIndicator(userId, indicator, organizationId);
|
||||||
}
|
}
|
||||||
|
|
||||||
const clientId = oidc.entities.Client?.clientId;
|
const clientId = oidc.entities.Client?.clientId;
|
||||||
|
|
|
@ -10,6 +10,10 @@ import {
|
||||||
type UserWithOrganizationRoles,
|
type UserWithOrganizationRoles,
|
||||||
type FeaturedUser,
|
type FeaturedUser,
|
||||||
type OrganizationScope,
|
type OrganizationScope,
|
||||||
|
type ResourceScopeEntity,
|
||||||
|
Scopes,
|
||||||
|
OrganizationRoleResourceScopeRelations,
|
||||||
|
Resources,
|
||||||
} from '@logto/schemas';
|
} from '@logto/schemas';
|
||||||
import { sql, type CommonQueryMethods } from '@silverhand/slonik';
|
import { sql, type CommonQueryMethods } from '@silverhand/slonik';
|
||||||
|
|
||||||
|
@ -18,7 +22,7 @@ import RelationQueries, {
|
||||||
type GetEntitiesOptions,
|
type GetEntitiesOptions,
|
||||||
TwoRelationsQueries,
|
TwoRelationsQueries,
|
||||||
} from '#src/utils/RelationQueries.js';
|
} from '#src/utils/RelationQueries.js';
|
||||||
import { convertToIdentifiers } from '#src/utils/sql.js';
|
import { conditionalSql, convertToIdentifiers } from '#src/utils/sql.js';
|
||||||
|
|
||||||
import { type userSearchKeys } from '../user.js';
|
import { type userSearchKeys } from '../user.js';
|
||||||
|
|
||||||
|
@ -196,6 +200,36 @@ export class RoleUserRelationQueries extends RelationQueries<
|
||||||
`);
|
`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the available resource scopes of a user in all organizations.
|
||||||
|
* if organizationId is provided, it will only search in that organization
|
||||||
|
*/
|
||||||
|
async getUserResourceScopes(
|
||||||
|
userId: string,
|
||||||
|
resourceIndicator: string,
|
||||||
|
organizationId?: string
|
||||||
|
): Promise<readonly ResourceScopeEntity[]> {
|
||||||
|
const { fields } = convertToIdentifiers(OrganizationRoleUserRelations, true);
|
||||||
|
const roleScopeRelations = convertToIdentifiers(OrganizationRoleResourceScopeRelations, true);
|
||||||
|
const scopes = convertToIdentifiers(Scopes, true);
|
||||||
|
const resources = convertToIdentifiers(Resources, true);
|
||||||
|
|
||||||
|
return this.pool.any<ResourceScopeEntity>(sql`
|
||||||
|
select distinct on (${scopes.fields.id})
|
||||||
|
${scopes.fields.id}, ${scopes.fields.name}
|
||||||
|
from ${this.table}
|
||||||
|
join ${roleScopeRelations.table}
|
||||||
|
on ${roleScopeRelations.fields.organizationRoleId} = ${fields.organizationRoleId}
|
||||||
|
join ${scopes.table}
|
||||||
|
on ${scopes.fields.id} = ${roleScopeRelations.fields.scopeId}
|
||||||
|
join ${resources.table}
|
||||||
|
on ${resources.fields.id} = ${scopes.fields.resourceId}
|
||||||
|
where ${fields.userId} = ${userId}
|
||||||
|
and ${resources.fields.indicator} = ${resourceIndicator}
|
||||||
|
${conditionalSql(organizationId, (value) => sql`and ${fields.organizationId} = ${value}`)}
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
/** Replace the roles of a user in an organization. */
|
/** Replace the roles of a user in an organization. */
|
||||||
async replace(organizationId: string, userId: string, roleIds: string[]) {
|
async replace(organizationId: string, userId: string, roleIds: string[]) {
|
||||||
const users = convertToIdentifiers(Users);
|
const users = convertToIdentifiers(Users);
|
||||||
|
|
|
@ -205,7 +205,7 @@ export const createUserQueries = (pool: CommonQueryMethods) => {
|
||||||
`
|
`
|
||||||
);
|
);
|
||||||
|
|
||||||
const findUsersByIds = async (userIds: string[]) =>
|
const findUsersByIds = async (userIds: string[]): Promise<readonly User[]> =>
|
||||||
userIds.length > 0
|
userIds.length > 0
|
||||||
? pool.any<User>(sql`
|
? pool.any<User>(sql`
|
||||||
select ${sql.join(Object.values(fields), sql`, `)}
|
select ${sql.join(Object.values(fields), sql`, `)}
|
||||||
|
|
|
@ -127,8 +127,8 @@ export default class MockClient {
|
||||||
await this.logto.handleSignInCallback(signInCallbackUri);
|
await this.logto.handleSignInCallback(signInCallbackUri);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async getAccessToken(resource?: string) {
|
public async getAccessToken(resource?: string, organizationId?: string) {
|
||||||
return this.logto.getAccessToken(resource);
|
return this.logto.getAccessToken(resource, organizationId);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async getRefreshToken(): Promise<Nullable<string>> {
|
public async getRefreshToken(): Promise<Nullable<string>> {
|
||||||
|
|
|
@ -0,0 +1,178 @@
|
||||||
|
import { UserScope } from '@logto/core-kit';
|
||||||
|
import { InteractionEvent, type Resource } from '@logto/schemas';
|
||||||
|
|
||||||
|
import { createResource, deleteResource, deleteUser, putInteraction } from '#src/api/index.js';
|
||||||
|
import { createScope, deleteScope } from '#src/api/scope.js';
|
||||||
|
import MockClient from '#src/client/index.js';
|
||||||
|
import { processSession } from '#src/helpers/client.js';
|
||||||
|
import { createUserByAdmin } from '#src/helpers/index.js';
|
||||||
|
import { OrganizationApiTest } from '#src/helpers/organization.js';
|
||||||
|
import { enableAllPasswordSignInMethods } from '#src/helpers/sign-in-experience.js';
|
||||||
|
import { generateUsername, generatePassword, getAccessTokenPayload } from '#src/utils.js';
|
||||||
|
|
||||||
|
describe('get access token for organization API resource', () => {
|
||||||
|
const username = generateUsername();
|
||||||
|
const password = generatePassword();
|
||||||
|
const testApiResourceInfo: Pick<Resource, 'name' | 'indicator'> = {
|
||||||
|
name: 'test-api-resource',
|
||||||
|
indicator: 'https://foo.logto.io/api',
|
||||||
|
};
|
||||||
|
const scopeName = 'read';
|
||||||
|
const scopeName2 = 'read:other';
|
||||||
|
|
||||||
|
/* eslint-disable @silverhand/fp/no-let */
|
||||||
|
let testApiResourceId: string;
|
||||||
|
let testApiScopeId: string;
|
||||||
|
let testApiScopeId2: string;
|
||||||
|
let testUserId: string;
|
||||||
|
let testOrganizationId: string;
|
||||||
|
let testOrganizationId2: string;
|
||||||
|
/* eslint-enable @silverhand/fp/no-let */
|
||||||
|
|
||||||
|
const organizationApi = new OrganizationApiTest();
|
||||||
|
|
||||||
|
/* eslint-disable @silverhand/fp/no-mutation */
|
||||||
|
beforeAll(async () => {
|
||||||
|
const user = await createUserByAdmin({ username, password });
|
||||||
|
testUserId = user.id;
|
||||||
|
const testApiResource = await createResource(
|
||||||
|
testApiResourceInfo.name,
|
||||||
|
testApiResourceInfo.indicator
|
||||||
|
);
|
||||||
|
testApiResourceId = testApiResource.id;
|
||||||
|
const scope = await createScope(testApiResource.id, scopeName);
|
||||||
|
testApiScopeId = scope.id;
|
||||||
|
const scope2 = await createScope(testApiResource.id, scopeName2);
|
||||||
|
testApiScopeId2 = scope2.id;
|
||||||
|
|
||||||
|
const organization = await organizationApi.create({ name: 'org1' });
|
||||||
|
testOrganizationId = organization.id;
|
||||||
|
await organizationApi.addUsers(testOrganizationId, [user.id]);
|
||||||
|
const role = await organizationApi.roleApi.create({ name: 'role1' });
|
||||||
|
await organizationApi.roleApi.addResourceScopes(role.id, [scope.id]);
|
||||||
|
await organizationApi.addUserRoles(testOrganizationId, user.id, [role.id]);
|
||||||
|
|
||||||
|
const organization2 = await organizationApi.create({ name: 'org2' });
|
||||||
|
testOrganizationId2 = organization2.id;
|
||||||
|
await organizationApi.addUsers(testOrganizationId2, [user.id]);
|
||||||
|
const role2 = await organizationApi.roleApi.create({ name: 'role2' });
|
||||||
|
await organizationApi.roleApi.addResourceScopes(role2.id, [scope2.id]);
|
||||||
|
await organizationApi.addUserRoles(testOrganizationId2, user.id, [role2.id]);
|
||||||
|
|
||||||
|
await enableAllPasswordSignInMethods();
|
||||||
|
});
|
||||||
|
/* eslint-enable @silverhand/fp/no-mutation */
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
if (testApiResourceId && testApiScopeId && testApiScopeId2) {
|
||||||
|
await deleteScope(testApiResourceId, testApiScopeId);
|
||||||
|
await deleteScope(testApiResourceId, testApiScopeId2);
|
||||||
|
await deleteResource(testApiResourceId);
|
||||||
|
}
|
||||||
|
if (testUserId) {
|
||||||
|
await deleteUser(testUserId);
|
||||||
|
}
|
||||||
|
await organizationApi.cleanUp();
|
||||||
|
await organizationApi.roleApi.cleanUp();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('can sign in and get access token with resource and organization_id', async () => {
|
||||||
|
const client = new MockClient({
|
||||||
|
resources: [testApiResourceInfo.indicator],
|
||||||
|
scopes: [scopeName, scopeName2, UserScope.Organizations],
|
||||||
|
});
|
||||||
|
await client.initSession();
|
||||||
|
await client.successSend(putInteraction, {
|
||||||
|
event: InteractionEvent.SignIn,
|
||||||
|
identifier: { username, password },
|
||||||
|
});
|
||||||
|
const { redirectTo } = await client.submitInteraction();
|
||||||
|
await processSession(client, redirectTo);
|
||||||
|
const accessToken = await client.getAccessToken(
|
||||||
|
testApiResourceInfo.indicator,
|
||||||
|
testOrganizationId
|
||||||
|
);
|
||||||
|
|
||||||
|
// No scopeName2, because we narrow down to only organization1
|
||||||
|
expect(getAccessTokenPayload(accessToken)).toHaveProperty('scope', scopeName);
|
||||||
|
expect(getAccessTokenPayload(accessToken)).toHaveProperty(
|
||||||
|
'organization_id',
|
||||||
|
testOrganizationId
|
||||||
|
);
|
||||||
|
expect(getAccessTokenPayload(accessToken)).toHaveProperty('aud', testApiResourceInfo.indicator);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('can sign in and get normal access token with all scopes', async () => {
|
||||||
|
const client = new MockClient({
|
||||||
|
resources: [testApiResourceInfo.indicator],
|
||||||
|
scopes: [scopeName, scopeName2],
|
||||||
|
});
|
||||||
|
await client.initSession();
|
||||||
|
await client.successSend(putInteraction, {
|
||||||
|
event: InteractionEvent.SignIn,
|
||||||
|
identifier: { username, password },
|
||||||
|
});
|
||||||
|
const { redirectTo } = await client.submitInteraction();
|
||||||
|
await processSession(client, redirectTo);
|
||||||
|
const accessToken = await client.getAccessToken(testApiResourceInfo.indicator);
|
||||||
|
|
||||||
|
expect(getAccessTokenPayload(accessToken)).toHaveProperty(
|
||||||
|
'scope',
|
||||||
|
[scopeName, scopeName2].join(' ')
|
||||||
|
);
|
||||||
|
expect(getAccessTokenPayload(accessToken)).not.toHaveProperty(
|
||||||
|
'organization_id',
|
||||||
|
testOrganizationId
|
||||||
|
);
|
||||||
|
expect(getAccessTokenPayload(accessToken)).toHaveProperty('aud', testApiResourceInfo.indicator);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw if the user is not in the organization', async () => {
|
||||||
|
const username = generateUsername();
|
||||||
|
const password = generatePassword();
|
||||||
|
const guestUser = await createUserByAdmin({ username, password });
|
||||||
|
const client = new MockClient({
|
||||||
|
resources: [testApiResourceInfo.indicator],
|
||||||
|
scopes: [scopeName, UserScope.Organizations],
|
||||||
|
});
|
||||||
|
await client.initSession();
|
||||||
|
await client.successSend(putInteraction, {
|
||||||
|
event: InteractionEvent.SignIn,
|
||||||
|
identifier: { username, password },
|
||||||
|
});
|
||||||
|
const { redirectTo } = await client.submitInteraction();
|
||||||
|
await processSession(client, redirectTo);
|
||||||
|
await expect(
|
||||||
|
client.getAccessToken(testApiResourceInfo.indicator, testOrganizationId)
|
||||||
|
).rejects.toThrow();
|
||||||
|
await deleteUser(guestUser.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not get the scope if the user organization role does not have the scope', async () => {
|
||||||
|
const username = generateUsername();
|
||||||
|
const password = generatePassword();
|
||||||
|
const guestUser = await createUserByAdmin({ username, password });
|
||||||
|
await organizationApi.addUsers(testOrganizationId, [guestUser.id]);
|
||||||
|
const role = await organizationApi.roleApi.create({ name: 'role3' });
|
||||||
|
// Noted that we do not add the scope to the role.
|
||||||
|
await organizationApi.addUserRoles(testOrganizationId, guestUser.id, [role.id]);
|
||||||
|
const client = new MockClient({
|
||||||
|
resources: [testApiResourceInfo.indicator],
|
||||||
|
scopes: [scopeName, UserScope.Organizations],
|
||||||
|
});
|
||||||
|
await client.initSession();
|
||||||
|
await client.successSend(putInteraction, {
|
||||||
|
event: InteractionEvent.SignIn,
|
||||||
|
identifier: { username, password },
|
||||||
|
});
|
||||||
|
const { redirectTo } = await client.submitInteraction();
|
||||||
|
await processSession(client, redirectTo);
|
||||||
|
const accessToken = await client.getAccessToken(
|
||||||
|
testApiResourceInfo.indicator,
|
||||||
|
testOrganizationId
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(getAccessTokenPayload(accessToken)).not.toHaveProperty('scope', scopeName);
|
||||||
|
await deleteUser(guestUser.id);
|
||||||
|
});
|
||||||
|
});
|
Loading…
Reference in a new issue