Skip to content

Commit

Permalink
feat(core,phrases): add POST /configs/jwt-customizer API
Browse files Browse the repository at this point in the history
  • Loading branch information
darcyYe committed Mar 5, 2024
1 parent ada9c4d commit 7c4019b
Show file tree
Hide file tree
Showing 38 changed files with 409 additions and 21 deletions.
26 changes: 25 additions & 1 deletion packages/core/src/__mocks__/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -159,7 +159,7 @@ export const mockCookieKeys: OidcConfigKey[] = [
{ id: 'cookie', value: 'bar', createdAt: 987_654_321 },
];

export const mockLogtoConfigs: LogtoConfig[] = [
const mockLogtoConfigs: LogtoConfig[] = [
{
tenantId: 'fake_tenant',
key: LogtoOidcConfigKey.PrivateKeys,
Expand All @@ -172,6 +172,14 @@ export const mockLogtoConfigs: LogtoConfig[] = [
},
];

export const mockLogtoConfigRows = {
rows: mockLogtoConfigs,
rowCount: mockLogtoConfigs.length,
command: 'SELECT' as const,
fields: [],
notices: [],
};

export const mockPasscode: Passcode = {
tenantId: 'fake_tenant',
id: 'foo',
Expand All @@ -198,3 +206,19 @@ export const mockApplicationRole: ApplicationsRole = {
applicationId: 'application_id',
roleId: 'role_id',
};

export const mockJwtCustomizerConfigForAccessToken = {
tenantId: 'fake_tenant',
key: 'jwt.accessToken',
value: {
script: 'console.log("hello world");',
envVars: {
API_KEY: '<api-key>',
},
contextSample: {
user: {
username: 'user',
},
},
},
};
2 changes: 1 addition & 1 deletion packages/core/src/middleware/koa-guard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,7 @@ export const isGuardMiddleware = <Type extends IMiddleware>(
): function_ is WithGuardConfig<Type> =>
function_.name === 'guardMiddleware' && has(function_, 'config');

const tryParse = <Output, Definition extends ZodTypeDef, Input>(
export const tryParse = <Output, Definition extends ZodTypeDef, Input>(
type: 'query' | 'body' | 'params' | 'files',
guard: Optional<ZodType<Output, Definition, Input>>,
data: unknown
Expand Down
34 changes: 27 additions & 7 deletions packages/core/src/queries/logto-config.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,19 @@
import type {
AdminConsoleData,
LogtoConfig,
LogtoConfigKey,
LogtoOidcConfigKey,
OidcConfigKey,
import {
type jwtCustomizerConfigGuard,
LogtoTenantConfigKey,
LogtoConfigs,
type AdminConsoleData,
type LogtoConfig,
type LogtoConfigKey,
type LogtoOidcConfigKey,
type OidcConfigKey,
type LogtoJwtTokenKey,
type JwtCustomizerType,
} from '@logto/schemas';
import { LogtoTenantConfigKey, LogtoConfigs } from '@logto/schemas';
import { convertToIdentifiers } from '@logto/shared';
import type { CommonQueryMethods } from 'slonik';
import { sql } from 'slonik';
import type { z } from 'zod';

const { table, fields } = convertToIdentifiers(LogtoConfigs);

Expand Down Expand Up @@ -47,11 +52,26 @@ export const createLogtoConfigQueries = (pool: CommonQueryMethods) => {
returning *
`);

// Can not narrow down the type of value if we utilize `buildInsertIntoWithPool` method.
const insertJwtCustomizer = async <T extends LogtoJwtTokenKey>(
key: T,
value: z.infer<(typeof jwtCustomizerConfigGuard)[T]>
) =>
pool.one<{ key: T; value: JwtCustomizerType[T] }>(
sql`
insert into ${table} (${fields.key}, ${fields.value})
values (${key}, ${sql.jsonb(value)})
on conflict (${fields.tenantId}, ${fields.key}) do nothing
returning *
`
);

return {
getAdminConsoleConfig,
updateAdminConsoleConfig,
getCloudConnectionData,
getRowsByKeys,
updateOidcConfigsByKey,
insertJwtCustomizer,
};
};
46 changes: 46 additions & 0 deletions packages/core/src/routes/logto-config.openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,52 @@
}
}
}
},
"/api/configs/jwt-customizer/{tokenType}": {
"post": {
"summary": "Create JWT customizer",
"description": "Create a JWT customizer for the given token type.",
"parameters": [
{
"in": "path",
"name": "tokenType",
"description": "The token type to create a JWT customizer for."
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"properties": {
"script": {
"description": "The script of the JWT customizer."
},
"envVars": {
"description": "The environment variables for the JWT customizer."
},
"contextSample": {
"description": "The sample context for the JWT customizer script testing purpose."
},
"tokenSample": {
"description": "The sample raw token payload for the JWT customizer script testing purpose."
}
}
}
}
}
},
"responses": {
"201": {
"description": "The created JWT customizer."
},
"400": {
"description": "The request body is invalid."
},
"409": {
"description": "The JWT customizer already exists."
}
}
}
}
}
}
47 changes: 38 additions & 9 deletions packages/core/src/routes/logto-config.test.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
import { LogtoOidcConfigKey, type AdminConsoleData } from '@logto/schemas';
import { LogtoOidcConfigKey, type AdminConsoleData, LogtoJwtTokenKey } from '@logto/schemas';
import { generateStandardId } from '@logto/shared';
import { createMockUtils, pickDefault } from '@logto/shared/esm';
import Sinon from 'sinon';

import {
mockAdminConsoleData,
mockCookieKeys,
mockLogtoConfigs,
mockPrivateKeys,
mockLogtoConfigRows,
mockJwtCustomizerConfigForAccessToken,
} from '#src/__mocks__/index.js';
import { MockTenant } from '#src/test-utils/tenant.js';
import { createRequester } from '#src/utils/test-utils.js';
Expand Down Expand Up @@ -52,13 +53,8 @@ const logtoConfigQueries = {
},
}),
updateOidcConfigsByKey: jest.fn(),
getRowsByKeys: jest.fn(async () => ({
rows: mockLogtoConfigs,
rowCount: mockLogtoConfigs.length,
command: 'SELECT' as const,
fields: [],
notices: [],
})),
getRowsByKeys: jest.fn(async () => mockLogtoConfigRows),
insertJwtCustomizer: jest.fn(),
};

const logtoConfigLibraries = {
Expand Down Expand Up @@ -229,4 +225,37 @@ describe('configs routes', () => {
[newPrivateKey2, newPrivateKey]
);
});

it('POST /configs/jwt-customizer/:tokenType should add a record successfully', async () => {
logtoConfigQueries.getRowsByKeys.mockResolvedValueOnce({
...mockLogtoConfigRows,
rows: [],
rowCount: 0,
});
logtoConfigQueries.insertJwtCustomizer.mockResolvedValueOnce(
mockJwtCustomizerConfigForAccessToken
);
const response = await routeRequester
.post('/configs/jwt-customizer/access-token')
.send(mockJwtCustomizerConfigForAccessToken);
expect(logtoConfigQueries.insertJwtCustomizer).toHaveBeenCalledWith(
LogtoJwtTokenKey.AccessToken,
mockJwtCustomizerConfigForAccessToken
);
expect(response.status).toEqual(201);
expect(response.body).toEqual(mockJwtCustomizerConfigForAccessToken.value);
});

it('POST /configs/jwt-customizer/:tokenType should fail ', async () => {
logtoConfigQueries.getRowsByKeys.mockResolvedValueOnce({
...mockLogtoConfigRows,
rows: [mockJwtCustomizerConfigForAccessToken],
rowCount: 1,
});
const response = await routeRequester
.post('/configs/jwt-customizer/access-token')
.send(mockJwtCustomizerConfigForAccessToken);
expect(logtoConfigQueries.insertJwtCustomizer).not.toHaveBeenCalled();
expect(response.status).toEqual(409);
});
});
74 changes: 71 additions & 3 deletions packages/core/src/routes/logto-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,16 @@ import {
type OidcConfigKeysResponse,
type OidcConfigKey,
LogtoOidcConfigKeyType,
LogtoJwtTokenKeyType,
jwtCustomizerAccessTokenGuard,
jwtCustomizerClientCredentialsGuard,
LogtoJwtTokenKey,
} from '@logto/schemas';
import { z } from 'zod';

import RequestError from '#src/errors/RequestError/index.js';
import koaGuard from '#src/middleware/koa-guard.js';
import koaGuard, { tryParse } from '#src/middleware/koa-guard.js';
import assertThat from '#src/utils/assert-that.js';
import { exportJWK } from '#src/utils/jwks.js';

import type { AuthedRouter, RouterInitArgs } from './types.js';
Expand All @@ -29,6 +34,11 @@ const getOidcConfigKeyDatabaseColumnName = (key: LogtoOidcConfigKeyType): LogtoO
? LogtoOidcConfigKey.PrivateKeys
: LogtoOidcConfigKey.CookieKeys;

const getLogtoJwtTokenKey = (key: LogtoJwtTokenKeyType): LogtoJwtTokenKey =>
key === LogtoJwtTokenKeyType.AccessToken
? LogtoJwtTokenKey.AccessToken
: LogtoJwtTokenKey.ClientCredentials;

/**
* Remove actual values of the private keys from response.
* @param type Logto config key DB column name. Values are either `oidc.privateKeys` or `oidc.cookieKeys`.
Expand Down Expand Up @@ -60,8 +70,13 @@ const getRedactedOidcKeyResponse = async (
export default function logtoConfigRoutes<T extends AuthedRouter>(
...[router, { queries, logtoConfigs, invalidateCache }]: RouterInitArgs<T>
) {
const { getAdminConsoleConfig, updateAdminConsoleConfig, updateOidcConfigsByKey } =
queries.logtoConfigs;
const {
getAdminConsoleConfig,
getRowsByKeys,
insertJwtCustomizer,
updateAdminConsoleConfig,
updateOidcConfigsByKey,
} = queries.logtoConfigs;
const { getOidcConfigs } = logtoConfigs;

router.get(
Expand Down Expand Up @@ -182,4 +197,57 @@ export default function logtoConfigRoutes<T extends AuthedRouter>(
return next();
}
);

router.post(
'/configs/jwt-customizer/:tokenType',
koaGuard({
params: z.object({
tokenType: z.nativeEnum(LogtoJwtTokenKeyType),
}),
/**
* Use `z.unknown()` to guard the request body as a JSON object, since the actual guard depends
* on the `tokenType` and we can not get the value of `tokenType` before parsing the request body,
* we will do more specific guard as long as we can get the value of `tokenType`.
*/
body: z.unknown(),
response: jwtCustomizerAccessTokenGuard.or(jwtCustomizerClientCredentialsGuard),
status: [201, 400, 409],
}),
async (ctx, next) => {
const {
params: { tokenType },
body,
} = ctx.guard;

// Manually implement the request body type check, the flow aligns with the actual `koaGuard()`.
// Use ternary operator to get the specific guard brings difficulties to type inference.
if (tokenType === LogtoJwtTokenKeyType.AccessToken) {
tryParse('body', jwtCustomizerAccessTokenGuard, body);
} else {
tryParse('body', jwtCustomizerClientCredentialsGuard, body);
}

const { rows } = await getRowsByKeys([getLogtoJwtTokenKey(tokenType)]);
assertThat(
rows.length === 0,
new RequestError({
code: 'logto_config.jwt.customizer_exists',
tokenType,
status: 409,
})
);

const jwtCustomizer = await insertJwtCustomizer(
getLogtoJwtTokenKey(tokenType),
// Since we applied the detailed guard manually, we can safely cast the `body` to the specific type.
// eslint-disable-next-line no-restricted-syntax
body as Parameters<typeof insertJwtCustomizer>[1]
);

ctx.status = 201;
ctx.body = jwtCustomizer.value;

return next();
}
);
}
8 changes: 8 additions & 0 deletions packages/integration-tests/src/api/logto-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@ import {
type AdminConsoleData,
type OidcConfigKeysResponse,
type LogtoOidcConfigKeyType,
type LogtoJwtTokenKeyType,
type JwtCustomizerAccessToken,
type JwtCustomizerClientCredentials,
} from '@logto/schemas';

import { authedAdminApi } from './api.js';
Expand Down Expand Up @@ -30,3 +33,8 @@ export const rotateOidcKeys = async (
authedAdminApi
.post(`configs/oidc/${keyType}/rotate`, { json: { signingKeyAlgorithm } })
.json<OidcConfigKeysResponse[]>();

export const insertJwtCustomizer = async (keyType: LogtoJwtTokenKeyType, value: unknown) =>
authedAdminApi
.post(`configs/jwt-customizer/${keyType}`, { json: value })
.json<JwtCustomizerAccessToken | JwtCustomizerClientCredentials>();
Loading

0 comments on commit 7c4019b

Please sign in to comment.