mirror of
https://github.com/logto-io/logto.git
synced 2024-12-16 20:26:19 -05:00
refactor(core): reorg and separate logto-config APIs into files
This commit is contained in:
parent
e34cfd812a
commit
c1722c8793
14 changed files with 474 additions and 291 deletions
|
@ -11,7 +11,13 @@ import type {
|
||||||
Scope,
|
Scope,
|
||||||
UsersRole,
|
UsersRole,
|
||||||
} from '@logto/schemas';
|
} from '@logto/schemas';
|
||||||
import { RoleType, ApplicationType, LogtoOidcConfigKey, DomainStatus } from '@logto/schemas';
|
import {
|
||||||
|
RoleType,
|
||||||
|
ApplicationType,
|
||||||
|
LogtoOidcConfigKey,
|
||||||
|
DomainStatus,
|
||||||
|
LogtoJwtTokenKey,
|
||||||
|
} from '@logto/schemas';
|
||||||
|
|
||||||
import { protectedAppSignInCallbackUrl } from '#src/constants/index.js';
|
import { protectedAppSignInCallbackUrl } from '#src/constants/index.js';
|
||||||
import { mockId } from '#src/test-utils/nanoid.js';
|
import { mockId } from '#src/test-utils/nanoid.js';
|
||||||
|
@ -209,7 +215,7 @@ export const mockApplicationRole: ApplicationsRole = {
|
||||||
|
|
||||||
export const mockJwtCustomizerConfigForAccessToken = {
|
export const mockJwtCustomizerConfigForAccessToken = {
|
||||||
tenantId: 'fake_tenant',
|
tenantId: 'fake_tenant',
|
||||||
key: 'jwt.accessToken',
|
key: LogtoJwtTokenKey.AccessToken,
|
||||||
value: {
|
value: {
|
||||||
script: 'console.log("hello world");',
|
script: 'console.log("hello world");',
|
||||||
envVars: {
|
envVars: {
|
||||||
|
@ -222,3 +228,14 @@ export const mockJwtCustomizerConfigForAccessToken = {
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const mockJwtCustomizerConfigForClientCredentials = {
|
||||||
|
tenantId: 'fake_tenant',
|
||||||
|
key: LogtoJwtTokenKey.ClientCredentials,
|
||||||
|
value: {
|
||||||
|
script: 'console.log("hello world");',
|
||||||
|
envVars: {
|
||||||
|
API_KEY: '<api-key>',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
|
@ -36,6 +36,7 @@ const logtoConfigs: LogtoConfigLibrary = {
|
||||||
getOidcConfigs: jest.fn(),
|
getOidcConfigs: jest.fn(),
|
||||||
upsertJwtCustomizer: jest.fn(),
|
upsertJwtCustomizer: jest.fn(),
|
||||||
getJwtCustomizer: jest.fn(),
|
getJwtCustomizer: jest.fn(),
|
||||||
|
getJwtCustomizers: jest.fn(),
|
||||||
};
|
};
|
||||||
|
|
||||||
describe('getAccessToken()', () => {
|
describe('getAccessToken()', () => {
|
||||||
|
|
|
@ -5,8 +5,9 @@ import {
|
||||||
LogtoOidcConfigKey,
|
LogtoOidcConfigKey,
|
||||||
jwtCustomizerConfigGuard,
|
jwtCustomizerConfigGuard,
|
||||||
LogtoConfigs,
|
LogtoConfigs,
|
||||||
|
LogtoJwtTokenKey,
|
||||||
} from '@logto/schemas';
|
} from '@logto/schemas';
|
||||||
import type { LogtoOidcConfigType, LogtoJwtTokenKey, CloudConnectionData } from '@logto/schemas';
|
import type { LogtoOidcConfigType, CloudConnectionData, JwtCustomizerType } from '@logto/schemas';
|
||||||
import chalk from 'chalk';
|
import chalk from 'chalk';
|
||||||
import { z, ZodError } from 'zod';
|
import { z, ZodError } from 'zod';
|
||||||
|
|
||||||
|
@ -95,5 +96,34 @@ export const createLogtoConfigLibrary = ({
|
||||||
return z.object({ value: jwtCustomizerConfigGuard[key] }).parse(rows[0]).value;
|
return z.object({ value: jwtCustomizerConfigGuard[key] }).parse(rows[0]).value;
|
||||||
};
|
};
|
||||||
|
|
||||||
return { getOidcConfigs, getCloudConnectionData, upsertJwtCustomizer, getJwtCustomizer };
|
const getJwtCustomizers = async (): Promise<Partial<JwtCustomizerType>> => {
|
||||||
|
try {
|
||||||
|
const { rows } = await getRowsByKeys(Object.values(LogtoJwtTokenKey));
|
||||||
|
|
||||||
|
return z
|
||||||
|
.object(jwtCustomizerConfigGuard)
|
||||||
|
.partial()
|
||||||
|
.parse(Object.fromEntries(rows.map(({ key, value }) => [key, value])));
|
||||||
|
} catch (error: unknown) {
|
||||||
|
if (error instanceof ZodError) {
|
||||||
|
consoleLog.error(
|
||||||
|
error.issues
|
||||||
|
.map(({ message, path }) => `${message} at ${chalk.green(path.join('.'))}`)
|
||||||
|
.join('\n')
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
consoleLog.error(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error('Failed to get JWT customizers');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
getOidcConfigs,
|
||||||
|
getCloudConnectionData,
|
||||||
|
upsertJwtCustomizer,
|
||||||
|
getJwtCustomizer,
|
||||||
|
getJwtCustomizers,
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
|
@ -59,6 +59,7 @@ const cloudConnection = createCloudConnectionLibrary({
|
||||||
getOidcConfigs: jest.fn(),
|
getOidcConfigs: jest.fn(),
|
||||||
upsertJwtCustomizer: jest.fn(),
|
upsertJwtCustomizer: jest.fn(),
|
||||||
getJwtCustomizer: jest.fn(),
|
getJwtCustomizer: jest.fn(),
|
||||||
|
getJwtCustomizers: jest.fn(),
|
||||||
});
|
});
|
||||||
|
|
||||||
const getLogtoConnectors = jest.spyOn(connectorLibrary, 'getLogtoConnectors');
|
const getLogtoConnectors = jest.spyOn(connectorLibrary, 'getLogtoConnectors');
|
||||||
|
|
|
@ -25,7 +25,7 @@ import domainRoutes from './domain.js';
|
||||||
import hookRoutes from './hook.js';
|
import hookRoutes from './hook.js';
|
||||||
import interactionRoutes from './interaction/index.js';
|
import interactionRoutes from './interaction/index.js';
|
||||||
import logRoutes from './log.js';
|
import logRoutes from './log.js';
|
||||||
import logtoConfigRoutes from './logto-config.js';
|
import logtoConfigRoutes from './logto-config/index.js';
|
||||||
import organizationRoutes from './organization/index.js';
|
import organizationRoutes from './organization/index.js';
|
||||||
import resourceRoutes from './resource.js';
|
import resourceRoutes from './resource.js';
|
||||||
import resourceScopeRoutes from './resource.scope.js';
|
import resourceScopeRoutes from './resource.scope.js';
|
||||||
|
|
|
@ -1,15 +1,9 @@
|
||||||
import { LogtoOidcConfigKey, type AdminConsoleData, LogtoJwtTokenKey } from '@logto/schemas';
|
import { LogtoOidcConfigKey, type AdminConsoleData } from '@logto/schemas';
|
||||||
import { generateStandardId } from '@logto/shared';
|
import { generateStandardId } from '@logto/shared';
|
||||||
import { createMockUtils, pickDefault } from '@logto/shared/esm';
|
import { createMockUtils, pickDefault } from '@logto/shared/esm';
|
||||||
import Sinon from 'sinon';
|
import Sinon from 'sinon';
|
||||||
|
|
||||||
import {
|
import { mockAdminConsoleData, mockCookieKeys, mockPrivateKeys } from '#src/__mocks__/index.js';
|
||||||
mockAdminConsoleData,
|
|
||||||
mockCookieKeys,
|
|
||||||
mockPrivateKeys,
|
|
||||||
mockLogtoConfigRows,
|
|
||||||
mockJwtCustomizerConfigForAccessToken,
|
|
||||||
} from '#src/__mocks__/index.js';
|
|
||||||
import { MockTenant } from '#src/test-utils/tenant.js';
|
import { MockTenant } from '#src/test-utils/tenant.js';
|
||||||
import { createRequester } from '#src/utils/test-utils.js';
|
import { createRequester } from '#src/utils/test-utils.js';
|
||||||
|
|
||||||
|
@ -53,8 +47,6 @@ const logtoConfigQueries = {
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
updateOidcConfigsByKey: jest.fn(),
|
updateOidcConfigsByKey: jest.fn(),
|
||||||
getRowsByKeys: jest.fn(async () => mockLogtoConfigRows),
|
|
||||||
deleteJwtCustomizer: jest.fn(),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const logtoConfigLibraries = {
|
const logtoConfigLibraries = {
|
||||||
|
@ -62,11 +54,9 @@ const logtoConfigLibraries = {
|
||||||
[LogtoOidcConfigKey.PrivateKeys]: mockPrivateKeys,
|
[LogtoOidcConfigKey.PrivateKeys]: mockPrivateKeys,
|
||||||
[LogtoOidcConfigKey.CookieKeys]: mockCookieKeys,
|
[LogtoOidcConfigKey.CookieKeys]: mockCookieKeys,
|
||||||
})),
|
})),
|
||||||
upsertJwtCustomizer: jest.fn(),
|
|
||||||
getJwtCustomizer: jest.fn(),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const settingRoutes = await pickDefault(import('./logto-config.js'));
|
const settingRoutes = await pickDefault(import('./index.js'));
|
||||||
|
|
||||||
describe('configs routes', () => {
|
describe('configs routes', () => {
|
||||||
const tenantContext = new MockTenant(undefined, { logtoConfigs: logtoConfigQueries });
|
const tenantContext = new MockTenant(undefined, { logtoConfigs: logtoConfigQueries });
|
||||||
|
@ -227,61 +217,4 @@ describe('configs routes', () => {
|
||||||
[newPrivateKey2, newPrivateKey]
|
[newPrivateKey2, newPrivateKey]
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('PUT /configs/jwt-customizer/:tokenType should add a record successfully', async () => {
|
|
||||||
logtoConfigQueries.getRowsByKeys.mockResolvedValueOnce({
|
|
||||||
...mockLogtoConfigRows,
|
|
||||||
rows: [],
|
|
||||||
rowCount: 0,
|
|
||||||
});
|
|
||||||
logtoConfigLibraries.upsertJwtCustomizer.mockResolvedValueOnce(
|
|
||||||
mockJwtCustomizerConfigForAccessToken
|
|
||||||
);
|
|
||||||
const response = await routeRequester
|
|
||||||
.put(`/configs/jwt-customizer/access-token`)
|
|
||||||
.send(mockJwtCustomizerConfigForAccessToken.value);
|
|
||||||
expect(logtoConfigLibraries.upsertJwtCustomizer).toHaveBeenCalledWith(
|
|
||||||
LogtoJwtTokenKey.AccessToken,
|
|
||||||
mockJwtCustomizerConfigForAccessToken.value
|
|
||||||
);
|
|
||||||
expect(response.status).toEqual(201);
|
|
||||||
expect(response.body).toEqual(mockJwtCustomizerConfigForAccessToken.value);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('PUT /configs/jwt-customizer/:tokenType should update a record successfully', async () => {
|
|
||||||
logtoConfigQueries.getRowsByKeys.mockResolvedValueOnce({
|
|
||||||
...mockLogtoConfigRows,
|
|
||||||
rows: [mockJwtCustomizerConfigForAccessToken],
|
|
||||||
rowCount: 1,
|
|
||||||
});
|
|
||||||
logtoConfigLibraries.upsertJwtCustomizer.mockResolvedValueOnce(
|
|
||||||
mockJwtCustomizerConfigForAccessToken
|
|
||||||
);
|
|
||||||
const response = await routeRequester
|
|
||||||
.put('/configs/jwt-customizer/access-token')
|
|
||||||
.send(mockJwtCustomizerConfigForAccessToken.value);
|
|
||||||
expect(logtoConfigLibraries.upsertJwtCustomizer).toHaveBeenCalledWith(
|
|
||||||
LogtoJwtTokenKey.AccessToken,
|
|
||||||
mockJwtCustomizerConfigForAccessToken.value
|
|
||||||
);
|
|
||||||
expect(response.status).toEqual(200);
|
|
||||||
expect(response.body).toEqual(mockJwtCustomizerConfigForAccessToken.value);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('GET /configs/jwt-customizer/:tokenType should return the record', async () => {
|
|
||||||
logtoConfigLibraries.getJwtCustomizer.mockResolvedValueOnce(
|
|
||||||
mockJwtCustomizerConfigForAccessToken.value
|
|
||||||
);
|
|
||||||
const response = await routeRequester.get('/configs/jwt-customizer/access-token');
|
|
||||||
expect(response.status).toEqual(200);
|
|
||||||
expect(response.body).toEqual(mockJwtCustomizerConfigForAccessToken.value);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('DELETE /configs/jwt-customizer/:tokenType should delete the record', async () => {
|
|
||||||
const response = await routeRequester.delete('/configs/jwt-customizer/client-credentials');
|
|
||||||
expect(logtoConfigQueries.deleteJwtCustomizer).toHaveBeenCalledWith(
|
|
||||||
LogtoJwtTokenKey.ClientCredentials
|
|
||||||
);
|
|
||||||
expect(response.status).toEqual(204);
|
|
||||||
});
|
|
||||||
});
|
});
|
189
packages/core/src/routes/logto-config/index.ts
Normal file
189
packages/core/src/routes/logto-config/index.ts
Normal file
|
@ -0,0 +1,189 @@
|
||||||
|
import crypto from 'node:crypto';
|
||||||
|
|
||||||
|
import {
|
||||||
|
generateOidcCookieKey,
|
||||||
|
generateOidcPrivateKey,
|
||||||
|
} from '@logto/cli/lib/commands/database/utils.js';
|
||||||
|
import {
|
||||||
|
LogtoOidcConfigKey,
|
||||||
|
adminConsoleDataGuard,
|
||||||
|
oidcConfigKeysResponseGuard,
|
||||||
|
SupportedSigningKeyAlgorithm,
|
||||||
|
type OidcConfigKeysResponse,
|
||||||
|
type OidcConfigKey,
|
||||||
|
LogtoOidcConfigKeyType,
|
||||||
|
} from '@logto/schemas';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import RequestError from '#src/errors/RequestError/index.js';
|
||||||
|
import koaGuard from '#src/middleware/koa-guard.js';
|
||||||
|
import { exportJWK } from '#src/utils/jwks.js';
|
||||||
|
|
||||||
|
import type { AuthedRouter, RouterInitArgs } from '../types.js';
|
||||||
|
|
||||||
|
import logtoConfigJwtCustomizerRoutes from './jwt-customizer.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Provide a simple API router key type and DB config key mapping
|
||||||
|
*/
|
||||||
|
const getOidcConfigKeyDatabaseColumnName = (key: LogtoOidcConfigKeyType): LogtoOidcConfigKey =>
|
||||||
|
key === LogtoOidcConfigKeyType.PrivateKeys
|
||||||
|
? LogtoOidcConfigKey.PrivateKeys
|
||||||
|
: LogtoOidcConfigKey.CookieKeys;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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`.
|
||||||
|
* @param keys Logto OIDC private keys.
|
||||||
|
* @returns Redacted Logto OIDC private keys without actual private key value.
|
||||||
|
*/
|
||||||
|
const getRedactedOidcKeyResponse = async (
|
||||||
|
type: LogtoOidcConfigKey,
|
||||||
|
keys: OidcConfigKey[]
|
||||||
|
): Promise<OidcConfigKeysResponse[]> =>
|
||||||
|
Promise.all(
|
||||||
|
keys.map(async ({ id, value, createdAt }) => {
|
||||||
|
if (type === LogtoOidcConfigKey.PrivateKeys) {
|
||||||
|
const jwk = await exportJWK(crypto.createPrivateKey(value));
|
||||||
|
const parseResult = oidcConfigKeysResponseGuard.safeParse({
|
||||||
|
id,
|
||||||
|
createdAt,
|
||||||
|
signingKeyAlgorithm: jwk.kty,
|
||||||
|
});
|
||||||
|
if (!parseResult.success) {
|
||||||
|
throw new RequestError({ code: 'request.general', status: 422 });
|
||||||
|
}
|
||||||
|
return parseResult.data;
|
||||||
|
}
|
||||||
|
return { id, createdAt };
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
export default function logtoConfigRoutes<T extends AuthedRouter>(
|
||||||
|
...[router, tenant]: RouterInitArgs<T>
|
||||||
|
) {
|
||||||
|
const { getAdminConsoleConfig, updateAdminConsoleConfig, updateOidcConfigsByKey } =
|
||||||
|
tenant.queries.logtoConfigs;
|
||||||
|
const { getOidcConfigs } = tenant.logtoConfigs;
|
||||||
|
|
||||||
|
router.get(
|
||||||
|
'/configs/admin-console',
|
||||||
|
koaGuard({ response: adminConsoleDataGuard, status: [200, 404] }),
|
||||||
|
async (ctx, next) => {
|
||||||
|
const { value } = await getAdminConsoleConfig();
|
||||||
|
ctx.body = value;
|
||||||
|
|
||||||
|
return next();
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
router.patch(
|
||||||
|
'/configs/admin-console',
|
||||||
|
koaGuard({
|
||||||
|
body: adminConsoleDataGuard.partial(),
|
||||||
|
response: adminConsoleDataGuard,
|
||||||
|
status: [200, 404],
|
||||||
|
}),
|
||||||
|
async (ctx, next) => {
|
||||||
|
const { value } = await updateAdminConsoleConfig(ctx.guard.body);
|
||||||
|
ctx.body = value;
|
||||||
|
|
||||||
|
return next();
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
router.get(
|
||||||
|
'/configs/oidc/:keyType',
|
||||||
|
koaGuard({
|
||||||
|
params: z.object({
|
||||||
|
keyType: z.nativeEnum(LogtoOidcConfigKeyType),
|
||||||
|
}),
|
||||||
|
response: z.array(oidcConfigKeysResponseGuard),
|
||||||
|
status: [200],
|
||||||
|
}),
|
||||||
|
async (ctx, next) => {
|
||||||
|
const { keyType } = ctx.guard.params;
|
||||||
|
const configKey = getOidcConfigKeyDatabaseColumnName(keyType);
|
||||||
|
const configs = await getOidcConfigs();
|
||||||
|
|
||||||
|
// Remove actual values of the private keys from response
|
||||||
|
ctx.body = await getRedactedOidcKeyResponse(configKey, configs[configKey]);
|
||||||
|
|
||||||
|
return next();
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
router.delete(
|
||||||
|
'/configs/oidc/:keyType/:keyId',
|
||||||
|
koaGuard({
|
||||||
|
params: z.object({
|
||||||
|
keyType: z.nativeEnum(LogtoOidcConfigKeyType),
|
||||||
|
keyId: z.string(),
|
||||||
|
}),
|
||||||
|
status: [204, 404, 422],
|
||||||
|
}),
|
||||||
|
async (ctx, next) => {
|
||||||
|
const { keyType, keyId } = ctx.guard.params;
|
||||||
|
const configKey = getOidcConfigKeyDatabaseColumnName(keyType);
|
||||||
|
const configs = await getOidcConfigs();
|
||||||
|
const existingKeys = configs[configKey];
|
||||||
|
|
||||||
|
if (existingKeys.length <= 1) {
|
||||||
|
throw new RequestError({ code: 'oidc.key_required', status: 422 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!existingKeys.some(({ id }) => id === keyId)) {
|
||||||
|
throw new RequestError({ code: 'oidc.key_not_found', id: keyId, status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatedKeys = existingKeys.filter(({ id }) => id !== keyId);
|
||||||
|
|
||||||
|
await updateOidcConfigsByKey(configKey, updatedKeys);
|
||||||
|
void tenant.invalidateCache();
|
||||||
|
|
||||||
|
ctx.status = 204;
|
||||||
|
|
||||||
|
return next();
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
router.post(
|
||||||
|
'/configs/oidc/:keyType/rotate',
|
||||||
|
koaGuard({
|
||||||
|
params: z.object({
|
||||||
|
keyType: z.nativeEnum(LogtoOidcConfigKeyType),
|
||||||
|
}),
|
||||||
|
body: z.object({
|
||||||
|
signingKeyAlgorithm: z.nativeEnum(SupportedSigningKeyAlgorithm).optional(),
|
||||||
|
}),
|
||||||
|
response: z.array(oidcConfigKeysResponseGuard),
|
||||||
|
status: [200, 422],
|
||||||
|
}),
|
||||||
|
async (ctx, next) => {
|
||||||
|
const { keyType } = ctx.guard.params;
|
||||||
|
const { signingKeyAlgorithm } = ctx.guard.body;
|
||||||
|
const configKey = getOidcConfigKeyDatabaseColumnName(keyType);
|
||||||
|
const configs = await getOidcConfigs();
|
||||||
|
const existingKeys = configs[configKey];
|
||||||
|
|
||||||
|
const newPrivateKey =
|
||||||
|
configKey === LogtoOidcConfigKey.PrivateKeys
|
||||||
|
? await generateOidcPrivateKey(signingKeyAlgorithm)
|
||||||
|
: generateOidcCookieKey();
|
||||||
|
|
||||||
|
// Clamp and only keep the 2 most recent private keys.
|
||||||
|
// Also make sure the new key is always on top of the list.
|
||||||
|
const updatedKeys = [newPrivateKey, ...existingKeys].slice(0, 2);
|
||||||
|
|
||||||
|
await updateOidcConfigsByKey(configKey, updatedKeys);
|
||||||
|
void tenant.invalidateCache();
|
||||||
|
|
||||||
|
// Remove actual values of the private keys from response
|
||||||
|
ctx.body = await getRedactedOidcKeyResponse(configKey, updatedKeys);
|
||||||
|
|
||||||
|
return next();
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
logtoConfigJwtCustomizerRoutes(router, tenant);
|
||||||
|
}
|
111
packages/core/src/routes/logto-config/jwt-customizer.test.ts
Normal file
111
packages/core/src/routes/logto-config/jwt-customizer.test.ts
Normal file
|
@ -0,0 +1,111 @@
|
||||||
|
import { LogtoJwtTokenKey } from '@logto/schemas';
|
||||||
|
import { pickDefault } from '@logto/shared/esm';
|
||||||
|
import { pick } from '@silverhand/essentials';
|
||||||
|
import Sinon from 'sinon';
|
||||||
|
|
||||||
|
import {
|
||||||
|
mockLogtoConfigRows,
|
||||||
|
mockJwtCustomizerConfigForAccessToken,
|
||||||
|
mockJwtCustomizerConfigForClientCredentials,
|
||||||
|
} from '#src/__mocks__/index.js';
|
||||||
|
import { MockTenant } from '#src/test-utils/tenant.js';
|
||||||
|
import { createRequester } from '#src/utils/test-utils.js';
|
||||||
|
|
||||||
|
const { jest } = import.meta;
|
||||||
|
|
||||||
|
const logtoConfigQueries = {
|
||||||
|
getRowsByKeys: jest.fn(async () => mockLogtoConfigRows),
|
||||||
|
deleteJwtCustomizer: jest.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const logtoConfigLibraries = {
|
||||||
|
upsertJwtCustomizer: jest.fn(),
|
||||||
|
getJwtCustomizer: jest.fn(),
|
||||||
|
getJwtCustomizers: jest.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const settingRoutes = await pickDefault(import('./index.js'));
|
||||||
|
|
||||||
|
describe('configs JWT customizer routes', () => {
|
||||||
|
const tenantContext = new MockTenant(undefined, { logtoConfigs: logtoConfigQueries });
|
||||||
|
Sinon.stub(tenantContext, 'logtoConfigs').value(logtoConfigLibraries);
|
||||||
|
|
||||||
|
const routeRequester = createRequester({
|
||||||
|
authedRoutes: settingRoutes,
|
||||||
|
tenantContext,
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('PUT /configs/jwt-customizer/:tokenType should add a record successfully', async () => {
|
||||||
|
logtoConfigQueries.getRowsByKeys.mockResolvedValueOnce({
|
||||||
|
...mockLogtoConfigRows,
|
||||||
|
rows: [],
|
||||||
|
rowCount: 0,
|
||||||
|
});
|
||||||
|
logtoConfigLibraries.upsertJwtCustomizer.mockResolvedValueOnce(
|
||||||
|
mockJwtCustomizerConfigForAccessToken
|
||||||
|
);
|
||||||
|
const response = await routeRequester
|
||||||
|
.put(`/configs/jwt-customizer/access-token`)
|
||||||
|
.send(mockJwtCustomizerConfigForAccessToken.value);
|
||||||
|
expect(logtoConfigLibraries.upsertJwtCustomizer).toHaveBeenCalledWith(
|
||||||
|
LogtoJwtTokenKey.AccessToken,
|
||||||
|
mockJwtCustomizerConfigForAccessToken.value
|
||||||
|
);
|
||||||
|
expect(response.status).toEqual(201);
|
||||||
|
expect(response.body).toEqual(mockJwtCustomizerConfigForAccessToken.value);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('PUT /configs/jwt-customizer/:tokenType should update a record successfully', async () => {
|
||||||
|
logtoConfigQueries.getRowsByKeys.mockResolvedValueOnce({
|
||||||
|
...mockLogtoConfigRows,
|
||||||
|
rows: [mockJwtCustomizerConfigForAccessToken],
|
||||||
|
rowCount: 1,
|
||||||
|
});
|
||||||
|
logtoConfigLibraries.upsertJwtCustomizer.mockResolvedValueOnce(
|
||||||
|
mockJwtCustomizerConfigForAccessToken
|
||||||
|
);
|
||||||
|
const response = await routeRequester
|
||||||
|
.put('/configs/jwt-customizer/access-token')
|
||||||
|
.send(mockJwtCustomizerConfigForAccessToken.value);
|
||||||
|
expect(logtoConfigLibraries.upsertJwtCustomizer).toHaveBeenCalledWith(
|
||||||
|
LogtoJwtTokenKey.AccessToken,
|
||||||
|
mockJwtCustomizerConfigForAccessToken.value
|
||||||
|
);
|
||||||
|
expect(response.status).toEqual(200);
|
||||||
|
expect(response.body).toEqual(mockJwtCustomizerConfigForAccessToken.value);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('GET /configs/jwt-customizer should return all records', async () => {
|
||||||
|
logtoConfigLibraries.getJwtCustomizers.mockResolvedValueOnce({
|
||||||
|
[LogtoJwtTokenKey.AccessToken]: mockJwtCustomizerConfigForAccessToken.value,
|
||||||
|
[LogtoJwtTokenKey.ClientCredentials]: mockJwtCustomizerConfigForClientCredentials.value,
|
||||||
|
});
|
||||||
|
const response = await routeRequester.get('/configs/jwt-customizer');
|
||||||
|
expect(response.status).toEqual(200);
|
||||||
|
expect(response.body).toEqual([
|
||||||
|
pick(mockJwtCustomizerConfigForAccessToken, 'key', 'value'),
|
||||||
|
pick(mockJwtCustomizerConfigForClientCredentials, 'key', 'value'),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('GET /configs/jwt-customizer/:tokenType should return the record', async () => {
|
||||||
|
logtoConfigLibraries.getJwtCustomizer.mockResolvedValueOnce(
|
||||||
|
mockJwtCustomizerConfigForAccessToken.value
|
||||||
|
);
|
||||||
|
const response = await routeRequester.get('/configs/jwt-customizer/access-token');
|
||||||
|
expect(response.status).toEqual(200);
|
||||||
|
expect(response.body).toEqual(mockJwtCustomizerConfigForAccessToken.value);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('DELETE /configs/jwt-customizer/:tokenType should delete the record', async () => {
|
||||||
|
const response = await routeRequester.delete('/configs/jwt-customizer/client-credentials');
|
||||||
|
expect(logtoConfigQueries.deleteJwtCustomizer).toHaveBeenCalledWith(
|
||||||
|
LogtoJwtTokenKey.ClientCredentials
|
||||||
|
);
|
||||||
|
expect(response.status).toEqual(204);
|
||||||
|
});
|
||||||
|
});
|
|
@ -1,57 +1,23 @@
|
||||||
import crypto from 'node:crypto';
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
generateOidcCookieKey,
|
|
||||||
generateOidcPrivateKey,
|
|
||||||
} from '@logto/cli/lib/commands/database/utils.js';
|
|
||||||
import {
|
|
||||||
LogtoOidcConfigKey,
|
|
||||||
adminConsoleDataGuard,
|
|
||||||
oidcConfigKeysResponseGuard,
|
|
||||||
SupportedSigningKeyAlgorithm,
|
|
||||||
type OidcConfigKeysResponse,
|
|
||||||
type OidcConfigKey,
|
|
||||||
LogtoOidcConfigKeyType,
|
|
||||||
accessTokenJwtCustomizerGuard,
|
accessTokenJwtCustomizerGuard,
|
||||||
clientCredentialsJwtCustomizerGuard,
|
clientCredentialsJwtCustomizerGuard,
|
||||||
LogtoJwtTokenKey,
|
LogtoJwtTokenKey,
|
||||||
LogtoJwtTokenPath,
|
LogtoJwtTokenPath,
|
||||||
jsonObjectGuard,
|
jsonObjectGuard,
|
||||||
type CustomJwtFetcher,
|
adminTenantId,
|
||||||
|
jwtCustomizerConfigsGuard,
|
||||||
jwtCustomizerTestRequestBodyGuard,
|
jwtCustomizerTestRequestBodyGuard,
|
||||||
type JwtCustomizerTestRequestBody,
|
type JwtCustomizerTestRequestBody,
|
||||||
|
type CustomJwtFetcher,
|
||||||
} from '@logto/schemas';
|
} from '@logto/schemas';
|
||||||
import { adminTenantId } from '@logto/schemas';
|
|
||||||
import { ResponseError } from '@withtyped/client';
|
import { ResponseError } from '@withtyped/client';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
import { EnvSet } from '#src/env-set/index.js';
|
import { EnvSet } from '#src/env-set/index.js';
|
||||||
import RequestError from '#src/errors/RequestError/index.js';
|
import RequestError from '#src/errors/RequestError/index.js';
|
||||||
import koaGuard, { parse } from '#src/middleware/koa-guard.js';
|
import koaGuard, { parse } from '#src/middleware/koa-guard.js';
|
||||||
import { exportJWK } from '#src/utils/jwks.js';
|
|
||||||
|
|
||||||
import type { AuthedRouter, RouterInitArgs } from './types.js';
|
import type { AuthedRouter, RouterInitArgs } from '../types.js';
|
||||||
|
|
||||||
/**
|
|
||||||
* Provide a simple API router key type and DB config key mapping
|
|
||||||
*/
|
|
||||||
const getOidcConfigKeyDatabaseColumnName = (key: LogtoOidcConfigKeyType): LogtoOidcConfigKey =>
|
|
||||||
key === LogtoOidcConfigKeyType.PrivateKeys
|
|
||||||
? LogtoOidcConfigKey.PrivateKeys
|
|
||||||
: LogtoOidcConfigKey.CookieKeys;
|
|
||||||
|
|
||||||
const getJwtTokenKeyAndBody = (tokenPath: LogtoJwtTokenPath, body: unknown) => {
|
|
||||||
if (tokenPath === LogtoJwtTokenPath.AccessToken) {
|
|
||||||
return {
|
|
||||||
key: LogtoJwtTokenKey.AccessToken,
|
|
||||||
body: parse('body', accessTokenJwtCustomizerGuard, body),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
key: LogtoJwtTokenKey.ClientCredentials,
|
|
||||||
body: parse('body', clientCredentialsJwtCustomizerGuard, body),
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Transpile the request body of the JWT customizer test API to the request body of the Cloud JWT customizer test API.
|
* Transpile the request body of the JWT customizer test API to the request body of the Cloud JWT customizer test API.
|
||||||
|
@ -84,167 +50,24 @@ const transpileJwtCustomizerTestRequestBody = (
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
const getJwtTokenKeyAndBody = (tokenPath: LogtoJwtTokenPath, body: unknown) => {
|
||||||
* Remove actual values of the private keys from response.
|
if (tokenPath === LogtoJwtTokenPath.AccessToken) {
|
||||||
* @param type Logto config key DB column name. Values are either `oidc.privateKeys` or `oidc.cookieKeys`.
|
return {
|
||||||
* @param keys Logto OIDC private keys.
|
key: LogtoJwtTokenKey.AccessToken,
|
||||||
* @returns Redacted Logto OIDC private keys without actual private key value.
|
body: parse('body', accessTokenJwtCustomizerGuard, body),
|
||||||
*/
|
};
|
||||||
const getRedactedOidcKeyResponse = async (
|
}
|
||||||
type: LogtoOidcConfigKey,
|
return {
|
||||||
keys: OidcConfigKey[]
|
key: LogtoJwtTokenKey.ClientCredentials,
|
||||||
): Promise<OidcConfigKeysResponse[]> =>
|
body: parse('body', clientCredentialsJwtCustomizerGuard, body),
|
||||||
Promise.all(
|
};
|
||||||
keys.map(async ({ id, value, createdAt }) => {
|
};
|
||||||
if (type === LogtoOidcConfigKey.PrivateKeys) {
|
|
||||||
const jwk = await exportJWK(crypto.createPrivateKey(value));
|
|
||||||
const parseResult = oidcConfigKeysResponseGuard.safeParse({
|
|
||||||
id,
|
|
||||||
createdAt,
|
|
||||||
signingKeyAlgorithm: jwk.kty,
|
|
||||||
});
|
|
||||||
if (!parseResult.success) {
|
|
||||||
throw new RequestError({ code: 'request.general', status: 422 });
|
|
||||||
}
|
|
||||||
return parseResult.data;
|
|
||||||
}
|
|
||||||
return { id, createdAt };
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
export default function logtoConfigRoutes<T extends AuthedRouter>(
|
export default function logtoConfigJwtCustomizerRoutes<T extends AuthedRouter>(
|
||||||
...[
|
...[router, { id: tenantId, queries, logtoConfigs, cloudConnection }]: RouterInitArgs<T>
|
||||||
router,
|
|
||||||
{ id: tenantId, queries, logtoConfigs, invalidateCache, cloudConnection },
|
|
||||||
]: RouterInitArgs<T>
|
|
||||||
) {
|
) {
|
||||||
const {
|
const { getRowsByKeys, deleteJwtCustomizer } = queries.logtoConfigs;
|
||||||
getAdminConsoleConfig,
|
const { upsertJwtCustomizer, getJwtCustomizer, getJwtCustomizers } = logtoConfigs;
|
||||||
getRowsByKeys,
|
|
||||||
updateAdminConsoleConfig,
|
|
||||||
updateOidcConfigsByKey,
|
|
||||||
deleteJwtCustomizer,
|
|
||||||
} = queries.logtoConfigs;
|
|
||||||
const { getOidcConfigs, upsertJwtCustomizer, getJwtCustomizer } = logtoConfigs;
|
|
||||||
|
|
||||||
router.get(
|
|
||||||
'/configs/admin-console',
|
|
||||||
koaGuard({ response: adminConsoleDataGuard, status: [200, 404] }),
|
|
||||||
async (ctx, next) => {
|
|
||||||
const { value } = await getAdminConsoleConfig();
|
|
||||||
ctx.body = value;
|
|
||||||
|
|
||||||
return next();
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
router.patch(
|
|
||||||
'/configs/admin-console',
|
|
||||||
koaGuard({
|
|
||||||
body: adminConsoleDataGuard.partial(),
|
|
||||||
response: adminConsoleDataGuard,
|
|
||||||
status: [200, 404],
|
|
||||||
}),
|
|
||||||
async (ctx, next) => {
|
|
||||||
const { value } = await updateAdminConsoleConfig(ctx.guard.body);
|
|
||||||
ctx.body = value;
|
|
||||||
|
|
||||||
return next();
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
router.get(
|
|
||||||
'/configs/oidc/:keyType',
|
|
||||||
koaGuard({
|
|
||||||
params: z.object({
|
|
||||||
keyType: z.nativeEnum(LogtoOidcConfigKeyType),
|
|
||||||
}),
|
|
||||||
response: z.array(oidcConfigKeysResponseGuard),
|
|
||||||
status: [200],
|
|
||||||
}),
|
|
||||||
async (ctx, next) => {
|
|
||||||
const { keyType } = ctx.guard.params;
|
|
||||||
const configKey = getOidcConfigKeyDatabaseColumnName(keyType);
|
|
||||||
const configs = await getOidcConfigs();
|
|
||||||
|
|
||||||
// Remove actual values of the private keys from response
|
|
||||||
ctx.body = await getRedactedOidcKeyResponse(configKey, configs[configKey]);
|
|
||||||
|
|
||||||
return next();
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
router.delete(
|
|
||||||
'/configs/oidc/:keyType/:keyId',
|
|
||||||
koaGuard({
|
|
||||||
params: z.object({
|
|
||||||
keyType: z.nativeEnum(LogtoOidcConfigKeyType),
|
|
||||||
keyId: z.string(),
|
|
||||||
}),
|
|
||||||
status: [204, 404, 422],
|
|
||||||
}),
|
|
||||||
async (ctx, next) => {
|
|
||||||
const { keyType, keyId } = ctx.guard.params;
|
|
||||||
const configKey = getOidcConfigKeyDatabaseColumnName(keyType);
|
|
||||||
const configs = await getOidcConfigs();
|
|
||||||
const existingKeys = configs[configKey];
|
|
||||||
|
|
||||||
if (existingKeys.length <= 1) {
|
|
||||||
throw new RequestError({ code: 'oidc.key_required', status: 422 });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!existingKeys.some(({ id }) => id === keyId)) {
|
|
||||||
throw new RequestError({ code: 'oidc.key_not_found', id: keyId, status: 404 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const updatedKeys = existingKeys.filter(({ id }) => id !== keyId);
|
|
||||||
|
|
||||||
await updateOidcConfigsByKey(configKey, updatedKeys);
|
|
||||||
void invalidateCache();
|
|
||||||
|
|
||||||
ctx.status = 204;
|
|
||||||
|
|
||||||
return next();
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
router.post(
|
|
||||||
'/configs/oidc/:keyType/rotate',
|
|
||||||
koaGuard({
|
|
||||||
params: z.object({
|
|
||||||
keyType: z.nativeEnum(LogtoOidcConfigKeyType),
|
|
||||||
}),
|
|
||||||
body: z.object({
|
|
||||||
signingKeyAlgorithm: z.nativeEnum(SupportedSigningKeyAlgorithm).optional(),
|
|
||||||
}),
|
|
||||||
response: z.array(oidcConfigKeysResponseGuard),
|
|
||||||
status: [200, 422],
|
|
||||||
}),
|
|
||||||
async (ctx, next) => {
|
|
||||||
const { keyType } = ctx.guard.params;
|
|
||||||
const { signingKeyAlgorithm } = ctx.guard.body;
|
|
||||||
const configKey = getOidcConfigKeyDatabaseColumnName(keyType);
|
|
||||||
const configs = await getOidcConfigs();
|
|
||||||
const existingKeys = configs[configKey];
|
|
||||||
|
|
||||||
const newPrivateKey =
|
|
||||||
configKey === LogtoOidcConfigKey.PrivateKeys
|
|
||||||
? await generateOidcPrivateKey(signingKeyAlgorithm)
|
|
||||||
: generateOidcCookieKey();
|
|
||||||
|
|
||||||
// Clamp and only keep the 2 most recent private keys.
|
|
||||||
// Also make sure the new key is always on top of the list.
|
|
||||||
const updatedKeys = [newPrivateKey, ...existingKeys].slice(0, 2);
|
|
||||||
|
|
||||||
await updateOidcConfigsByKey(configKey, updatedKeys);
|
|
||||||
void invalidateCache();
|
|
||||||
|
|
||||||
// Remove actual values of the private keys from response
|
|
||||||
ctx.body = await getRedactedOidcKeyResponse(configKey, updatedKeys);
|
|
||||||
|
|
||||||
return next();
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
router.put(
|
router.put(
|
||||||
'/configs/jwt-customizer/:tokenTypePath',
|
'/configs/jwt-customizer/:tokenTypePath',
|
||||||
|
@ -264,8 +87,8 @@ export default function logtoConfigRoutes<T extends AuthedRouter>(
|
||||||
status: [200, 201, 400, 403],
|
status: [200, 201, 400, 403],
|
||||||
}),
|
}),
|
||||||
async (ctx, next) => {
|
async (ctx, next) => {
|
||||||
const { isCloud, isUnitTest, isIntegrationTest } = EnvSet.values;
|
const { isCloud, isIntegrationTest } = EnvSet.values;
|
||||||
if (tenantId === adminTenantId && isCloud && !(isUnitTest || isIntegrationTest)) {
|
if (tenantId === adminTenantId && isCloud && !isIntegrationTest) {
|
||||||
throw new RequestError({
|
throw new RequestError({
|
||||||
code: 'jwt_customizer.can_not_create_for_admin_tenant',
|
code: 'jwt_customizer.can_not_create_for_admin_tenant',
|
||||||
status: 422,
|
status: 422,
|
||||||
|
@ -291,6 +114,21 @@ export default function logtoConfigRoutes<T extends AuthedRouter>(
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
router.get(
|
||||||
|
'/configs/jwt-customizer',
|
||||||
|
koaGuard({
|
||||||
|
response: jwtCustomizerConfigsGuard.array(),
|
||||||
|
status: [200],
|
||||||
|
}),
|
||||||
|
async (ctx, next) => {
|
||||||
|
const jwtCustomizer = await getJwtCustomizers();
|
||||||
|
ctx.body = Object.values(LogtoJwtTokenKey)
|
||||||
|
.filter((key) => jwtCustomizer[key])
|
||||||
|
.map((key) => ({ key, value: jwtCustomizer[key] }));
|
||||||
|
return next();
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
router.get(
|
router.get(
|
||||||
'/configs/jwt-customizer/:tokenTypePath',
|
'/configs/jwt-customizer/:tokenTypePath',
|
||||||
koaGuard({
|
koaGuard({
|
|
@ -105,6 +105,17 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/api/configs/jwt-customizer": {
|
||||||
|
"get": {
|
||||||
|
"summary": "Get all JWT customizers",
|
||||||
|
"description": "Get all JWT customizers for the tenant.",
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "The JWT customizers."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"/api/configs/jwt-customizer/{tokenTypePath}": {
|
"/api/configs/jwt-customizer/{tokenTypePath}": {
|
||||||
"put": {
|
"put": {
|
||||||
"summary": "Create or update JWT customizer",
|
"summary": "Create or update JWT customizer",
|
15
packages/integration-tests/src/__mocks__/jwt-customizer.ts
Normal file
15
packages/integration-tests/src/__mocks__/jwt-customizer.ts
Normal file
|
@ -0,0 +1,15 @@
|
||||||
|
export const clientCredentialsJwtCustomizerPayload = {
|
||||||
|
script: '',
|
||||||
|
envVars: {},
|
||||||
|
contextSample: {},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const accessTokenJwtCustomizerPayload = {
|
||||||
|
...clientCredentialsJwtCustomizerPayload,
|
||||||
|
contextSample: {
|
||||||
|
user: {
|
||||||
|
username: 'test',
|
||||||
|
id: 'fake-id',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
|
@ -5,6 +5,7 @@ import {
|
||||||
type LogtoOidcConfigKeyType,
|
type LogtoOidcConfigKeyType,
|
||||||
type AccessTokenJwtCustomizer,
|
type AccessTokenJwtCustomizer,
|
||||||
type ClientCredentialsJwtCustomizer,
|
type ClientCredentialsJwtCustomizer,
|
||||||
|
type JwtCustomizerConfigs,
|
||||||
} from '@logto/schemas';
|
} from '@logto/schemas';
|
||||||
|
|
||||||
import { authedAdminApi } from './api.js';
|
import { authedAdminApi } from './api.js';
|
||||||
|
@ -46,5 +47,8 @@ export const getJwtCustomizer = async (keyTypePath: 'access-token' | 'client-cre
|
||||||
.get(`configs/jwt-customizer/${keyTypePath}`)
|
.get(`configs/jwt-customizer/${keyTypePath}`)
|
||||||
.json<AccessTokenJwtCustomizer | ClientCredentialsJwtCustomizer>();
|
.json<AccessTokenJwtCustomizer | ClientCredentialsJwtCustomizer>();
|
||||||
|
|
||||||
|
export const getJwtCustomizers = async () =>
|
||||||
|
authedAdminApi.get(`configs/jwt-customizer`).json<JwtCustomizerConfigs[]>();
|
||||||
|
|
||||||
export const deleteJwtCustomizer = async (keyTypePath: 'access-token' | 'client-credentials') =>
|
export const deleteJwtCustomizer = async (keyTypePath: 'access-token' | 'client-credentials') =>
|
||||||
authedAdminApi.delete(`configs/jwt-customizer/${keyTypePath}`);
|
authedAdminApi.delete(`configs/jwt-customizer/${keyTypePath}`);
|
||||||
|
|
|
@ -2,8 +2,13 @@ import {
|
||||||
SupportedSigningKeyAlgorithm,
|
SupportedSigningKeyAlgorithm,
|
||||||
type AdminConsoleData,
|
type AdminConsoleData,
|
||||||
LogtoOidcConfigKeyType,
|
LogtoOidcConfigKeyType,
|
||||||
|
LogtoJwtTokenKey,
|
||||||
} from '@logto/schemas';
|
} from '@logto/schemas';
|
||||||
|
|
||||||
|
import {
|
||||||
|
accessTokenJwtCustomizerPayload,
|
||||||
|
clientCredentialsJwtCustomizerPayload,
|
||||||
|
} from '#src/__mocks__/jwt-customizer.js';
|
||||||
import {
|
import {
|
||||||
deleteOidcKey,
|
deleteOidcKey,
|
||||||
getAdminConsoleConfig,
|
getAdminConsoleConfig,
|
||||||
|
@ -12,6 +17,7 @@ import {
|
||||||
updateAdminConsoleConfig,
|
updateAdminConsoleConfig,
|
||||||
upsertJwtCustomizer,
|
upsertJwtCustomizer,
|
||||||
getJwtCustomizer,
|
getJwtCustomizer,
|
||||||
|
getJwtCustomizers,
|
||||||
deleteJwtCustomizer,
|
deleteJwtCustomizer,
|
||||||
} from '#src/api/index.js';
|
} from '#src/api/index.js';
|
||||||
import { expectRejects } from '#src/helpers/index.js';
|
import { expectRejects } from '#src/helpers/index.js';
|
||||||
|
@ -128,17 +134,6 @@ describe('admin console sign-in experience', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should successfully PUT/GET/DELETE a JWT customizer (access token)', async () => {
|
it('should successfully PUT/GET/DELETE a JWT customizer (access token)', async () => {
|
||||||
const accessTokenJwtCustomizerPayload = {
|
|
||||||
script: '',
|
|
||||||
envVars: {},
|
|
||||||
contextSample: {
|
|
||||||
user: {
|
|
||||||
username: 'test',
|
|
||||||
id: 'fake-id',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
await expectRejects(getJwtCustomizer('access-token'), {
|
await expectRejects(getJwtCustomizer('access-token'), {
|
||||||
code: 'entity.not_exists_with_id',
|
code: 'entity.not_exists_with_id',
|
||||||
status: 404,
|
status: 404,
|
||||||
|
@ -169,12 +164,6 @@ describe('admin console sign-in experience', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should successfully PUT/GET/DELETE a JWT customizer (client credentials)', async () => {
|
it('should successfully PUT/GET/DELETE a JWT customizer (client credentials)', async () => {
|
||||||
const clientCredentialsJwtCustomizerPayload = {
|
|
||||||
script: '',
|
|
||||||
envVars: {},
|
|
||||||
contextSample: {},
|
|
||||||
};
|
|
||||||
|
|
||||||
await expectRejects(getJwtCustomizer('client-credentials'), {
|
await expectRejects(getJwtCustomizer('client-credentials'), {
|
||||||
code: 'entity.not_exists_with_id',
|
code: 'entity.not_exists_with_id',
|
||||||
status: 404,
|
status: 404,
|
||||||
|
@ -206,4 +195,35 @@ describe('admin console sign-in experience', () => {
|
||||||
status: 404,
|
status: 404,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should successfully GET all JWT customizers', async () => {
|
||||||
|
await expect(getJwtCustomizers()).resolves.toEqual([]);
|
||||||
|
await upsertJwtCustomizer('access-token', accessTokenJwtCustomizerPayload);
|
||||||
|
await expect(getJwtCustomizers()).resolves.toEqual([
|
||||||
|
{
|
||||||
|
key: LogtoJwtTokenKey.AccessToken,
|
||||||
|
value: accessTokenJwtCustomizerPayload,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
await upsertJwtCustomizer('client-credentials', clientCredentialsJwtCustomizerPayload);
|
||||||
|
const jwtCustomizers = await getJwtCustomizers();
|
||||||
|
expect(jwtCustomizers).toHaveLength(2);
|
||||||
|
expect(jwtCustomizers).toContainEqual({
|
||||||
|
key: LogtoJwtTokenKey.AccessToken,
|
||||||
|
value: accessTokenJwtCustomizerPayload,
|
||||||
|
});
|
||||||
|
expect(jwtCustomizers).toContainEqual({
|
||||||
|
key: LogtoJwtTokenKey.ClientCredentials,
|
||||||
|
value: clientCredentialsJwtCustomizerPayload,
|
||||||
|
});
|
||||||
|
await deleteJwtCustomizer('access-token');
|
||||||
|
await expect(getJwtCustomizers()).resolves.toEqual([
|
||||||
|
{
|
||||||
|
key: LogtoJwtTokenKey.ClientCredentials,
|
||||||
|
value: clientCredentialsJwtCustomizerPayload,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
await deleteJwtCustomizer('client-credentials');
|
||||||
|
await expect(getJwtCustomizers()).resolves.toEqual([]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -90,6 +90,19 @@ export const jwtCustomizerConfigGuard: Readonly<{
|
||||||
[LogtoJwtTokenKey.ClientCredentials]: clientCredentialsJwtCustomizerGuard,
|
[LogtoJwtTokenKey.ClientCredentials]: clientCredentialsJwtCustomizerGuard,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const jwtCustomizerConfigsGuard = z.discriminatedUnion('key', [
|
||||||
|
z.object({
|
||||||
|
key: z.literal(LogtoJwtTokenKey.AccessToken),
|
||||||
|
value: accessTokenJwtCustomizerGuard,
|
||||||
|
}),
|
||||||
|
z.object({
|
||||||
|
key: z.literal(LogtoJwtTokenKey.ClientCredentials),
|
||||||
|
value: clientCredentialsJwtCustomizerGuard,
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
export type JwtCustomizerConfigs = z.infer<typeof jwtCustomizerConfigsGuard>;
|
||||||
|
|
||||||
/* --- Logto tenant configs --- */
|
/* --- Logto tenant configs --- */
|
||||||
export const adminConsoleDataGuard = z.object({
|
export const adminConsoleDataGuard = z.object({
|
||||||
signInExperienceCustomized: z.boolean(),
|
signInExperienceCustomized: z.boolean(),
|
||||||
|
|
Loading…
Reference in a new issue