Skip to content

Commit

Permalink
feat(core): handle access token with organization api resource
Browse files Browse the repository at this point in the history
  • Loading branch information
wangsijie committed Apr 9, 2024
1 parent bf471e1 commit 5b501cb
Show file tree
Hide file tree
Showing 7 changed files with 259 additions and 126 deletions.
7 changes: 6 additions & 1 deletion packages/core/src/libraries/user.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@ export const createUserLibrary = (queries: Queries) => {
usersRoles: { findUsersRolesByRoleId, findUsersRolesByUserId },
rolesScopes: { findRolesScopesByRoleIds },
scopes: { findScopesByIdsAndResourceIndicator },
organizations,
} = queries;

const generateUserId = async (retries = 500) =>
Expand Down Expand Up @@ -173,9 +174,13 @@ export const createUserLibrary = (queries: Queries) => {
): 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
);

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

Expand Down
159 changes: 159 additions & 0 deletions packages/core/src/oidc/extra-token-claims.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
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: EnvSet,
queries: Queries,
libraries: Libraries,
logtoConfigs: LogtoConfigLibrary,
cloudConnection: CloudConnectionLibrary
): Promise<UnknownObject | undefined> => {

Check warning on line 60 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#L60

[max-params] Async arrow function has too many parameters (7). Maximum allowed is 4.
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 130 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#L130

[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 */
40 changes: 25 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 Expand Up @@ -329,4 +314,29 @@ describe('organization token grant', () => {
aud: 'urn:logto:organization:some_org_id',
});
});

it('should go through normal process when both `organization_id` and `resource` are present', async () => {
const ctx = createOidcContext({
...validOidcContext,
params: {
...validOidcContext.params,
resource: 'https://some_resource/api',
},
});
stubRefreshToken(ctx);
stubGrant(ctx);
const tenant = new MockTenant();

Sinon.stub(tenant.queries.organizations.relations.users, 'exists').resolves(true);
Sinon.stub(tenant.queries.applications, 'findApplicationById').resolves(mockApplication);
Sinon.stub(tenant.queries.organizations.relations.rolesUsers, 'getUserScopes').resolves([
{ id: 'foo', name: 'foo' },
{ id: 'bar', name: 'bar' },
{ id: 'baz', name: 'baz' },
]);

// The resource is not existent, so it should throw invalid target error if the handler
// goes through the normal process.
await expect(mockHandler(tenant)(ctx, noop)).rejects.toThrow(errors.InvalidTarget);
});
});
21 changes: 10 additions & 11 deletions packages/core/src/oidc/grants/refresh-token.ts
Original file line number Diff line number Diff line change
Expand Up @@ -138,15 +138,11 @@ export const buildHandler: (
// 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.
if (!refreshToken.scopes.has(UserScope.Organizations)) {
throw new InsufficientScope('refresh token missing required scope', UserScope.Organizations);
}
// Does not allow requesting resource token when requesting organization token (yet).
if (params.resource) {
throw new InvalidRequest('resource is not allowed when requesting organization token');
}
if (
organizationId &&
!refreshToken.scopes.has(UserScope.Organizations) // Validate if the refresh token has the required scope from RFC 0001.
) {
throw new InsufficientScope('refresh token missing required scope', UserScope.Organizations);
}
/* === End RFC 0001 === */

Expand Down Expand Up @@ -313,8 +309,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, issur 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

0 comments on commit 5b501cb

Please sign in to comment.