Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(core): handle access token with organization api resource #5653

Merged
merged 1 commit into from
Apr 16, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 13 additions & 2 deletions packages/core/src/libraries/user.ts
wangsijie marked this conversation as resolved.
Show resolved Hide resolved
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@
usersRoles: { findUsersRolesByRoleId, findUsersRolesByUserId },
rolesScopes: { findRolesScopesByRoleIds },
scopes: { findScopesByIdsAndResourceIndicator },
organizations,
} = queries;

const generateUserId = async (retries = 500) =>
Expand Down Expand Up @@ -167,15 +168,25 @@
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
wangsijie marked this conversation as resolved.
Show resolved Hide resolved
*/
const findUserScopesForResourceIndicator = async (
userId: string,
resourceIndicator: string
resourceIndicator: string,
organizationId?: string
): Promise<readonly Scope[]> => {
const usersRoles = await findUsersRolesByUserId(userId);
const rolesScopes = await findRolesScopesByRoleIds(usersRoles.map(({ roleId }) => roleId));
const organizationScopes = await organizations.relations.rolesUsers.getUserResourceScopes(
userId,
resourceIndicator,
organizationId
);

const scopes = await findScopesByIdsAndResourceIndicator(
rolesScopes.map(({ scopeId }) => scopeId),
[...rolesScopes.map(({ scopeId }) => scopeId), ...organizationScopes.map(({ id }) => id)],
resourceIndicator
);

Expand All @@ -190,7 +201,7 @@
};

const addUserMfaVerification = async (userId: string, payload: BindMfa) => {
// TODO @sijie use jsonb array append

Check warning on line 204 in packages/core/src/libraries/user.ts

View workflow job for this annotation

GitHub Actions / ESLint Report Analysis

packages/core/src/libraries/user.ts#L204

[no-warning-comments] Unexpected 'todo' comment: 'TODO @sijie use jsonb array append'.
const { mfaVerifications } = await findUserById(userId);
await updateUserById(userId, {
mfaVerifications: [...mfaVerifications, converBindMfaToMfaVerification(payload)],
Expand Down
167 changes: 167 additions & 0 deletions packages/core/src/oidc/extra-token-claims.ts
Original file line number Diff line number Diff line change
@@ -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.
wangsijie marked this conversation as resolved.
Show resolved Hide resolved
*/
export const getExtraTokenClaimsForOrganizationApiResource = async (
ctx: KoaContextWithOIDC,
token: unknown
): Promise<UnknownObject | undefined> => {
const { isDevFeaturesEnabled } = EnvSet.values;

if (!isDevFeaturesEnabled) {
wangsijie marked this conversation as resolved.
Show resolved Hide resolved
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 */
wangsijie marked this conversation as resolved.
Show resolved Hide resolved
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.

Check warning on line 138 in packages/core/src/oidc/extra-token-claims.ts

View workflow job for this annotation

GitHub Actions / ESLint Report Analysis

packages/core/src/oidc/extra-token-claims.ts#L138

[no-warning-comments] Unexpected 'todo' comment: 'TODO (LOG-8555): the newly added...'.
// 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 */
15 changes: 0 additions & 15 deletions packages/core/src/oidc/grants/refresh-token.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -238,21 +238,6 @@ describe('organization token grant', () => {
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 () => {
const ctx = createOidcContext(validOidcContext);
stubRefreshToken(ctx);
Expand Down
11 changes: 7 additions & 4 deletions packages/core/src/oidc/grants/refresh-token.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 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 assertThat from '#src/utils/assert-that.js';

Expand Down Expand Up @@ -144,7 +144,7 @@ export const buildHandler: (
throw new InsufficientScope('refresh token missing required scope', UserScope.Organizations);
}
// 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');
}
}
Expand Down Expand Up @@ -313,8 +313,11 @@ export const buildHandler: (
/** The scopes requested by the client. If not provided, use the scopes from the refresh token. */
const scope = params.scope ? requestParamScopes : refreshToken.scopes;

/* === RFC 0001 === */
if (organizationId) {
// Note, issue organization token only if `params.resource` is not present.
// 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);
/** All available scopes for the user in the organization. */
const availableScopes = await queries.organizations.relations.rolesUsers
Expand Down
Loading
Loading