mirror of
https://github.com/logto-io/logto.git
synced 2025-03-24 22:41:28 -05:00
Merge pull request #5468 from logto-io/yemq-log-8284-add-PATCH-configs-jwt-customizer-API
feat(core): add PATCH /configs/jwt-customizer API
This commit is contained in:
commit
6cc5dd01d4
11 changed files with 253 additions and 18 deletions
|
@ -37,6 +37,7 @@ const logtoConfigs: LogtoConfigLibrary = {
|
|||
upsertJwtCustomizer: jest.fn(),
|
||||
getJwtCustomizer: jest.fn(),
|
||||
getJwtCustomizers: jest.fn(),
|
||||
updateJwtCustomizer: jest.fn(),
|
||||
};
|
||||
|
||||
describe('getAccessToken()', () => {
|
||||
|
|
|
@ -119,11 +119,22 @@ export const createLogtoConfigLibrary = ({
|
|||
}
|
||||
};
|
||||
|
||||
const updateJwtCustomizer = async <T extends LogtoJwtTokenKey>(
|
||||
key: T,
|
||||
value: JwtCustomizerType[T]
|
||||
): Promise<JwtCustomizerType[T]> => {
|
||||
const originValue = await getJwtCustomizer(key);
|
||||
const result = jwtCustomizerConfigGuard[key].parse({ ...originValue, ...value });
|
||||
const updatedRow = await upsertJwtCustomizer(key, result);
|
||||
return updatedRow.value;
|
||||
};
|
||||
|
||||
return {
|
||||
getOidcConfigs,
|
||||
getCloudConnectionData,
|
||||
upsertJwtCustomizer,
|
||||
getJwtCustomizer,
|
||||
getJwtCustomizers,
|
||||
updateJwtCustomizer,
|
||||
};
|
||||
};
|
||||
|
|
|
@ -60,6 +60,7 @@ const cloudConnection = createCloudConnectionLibrary({
|
|||
upsertJwtCustomizer: jest.fn(),
|
||||
getJwtCustomizer: jest.fn(),
|
||||
getJwtCustomizers: jest.fn(),
|
||||
updateJwtCustomizer: jest.fn(),
|
||||
});
|
||||
|
||||
const getLogtoConnectors = jest.spyOn(connectorLibrary, 'getLogtoConnectors');
|
||||
|
|
|
@ -22,6 +22,7 @@ const logtoConfigLibraries = {
|
|||
upsertJwtCustomizer: jest.fn(),
|
||||
getJwtCustomizer: jest.fn(),
|
||||
getJwtCustomizers: jest.fn(),
|
||||
updateJwtCustomizer: jest.fn(),
|
||||
};
|
||||
|
||||
const settingRoutes = await pickDefault(import('./index.js'));
|
||||
|
@ -79,6 +80,21 @@ describe('configs JWT customizer routes', () => {
|
|||
expect(response.body).toEqual(mockJwtCustomizerConfigForAccessToken.value);
|
||||
});
|
||||
|
||||
it('PATCH /configs/jwt-customizer/:tokenType should update a record successfully', async () => {
|
||||
logtoConfigLibraries.updateJwtCustomizer.mockResolvedValueOnce(
|
||||
mockJwtCustomizerConfigForAccessToken.value
|
||||
);
|
||||
const response = await routeRequester
|
||||
.patch('/configs/jwt-customizer/access-token')
|
||||
.send(mockJwtCustomizerConfigForAccessToken.value);
|
||||
expect(logtoConfigLibraries.updateJwtCustomizer).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,
|
||||
|
|
|
@ -34,7 +34,8 @@ export default function logtoConfigJwtCustomizerRoutes<T extends AuthedRouter>(
|
|||
...[router, { id: tenantId, queries, logtoConfigs, cloudConnection }]: RouterInitArgs<T>
|
||||
) {
|
||||
const { getRowsByKeys, deleteJwtCustomizer } = queries.logtoConfigs;
|
||||
const { upsertJwtCustomizer, getJwtCustomizer, getJwtCustomizers } = logtoConfigs;
|
||||
const { upsertJwtCustomizer, getJwtCustomizer, getJwtCustomizers, updateJwtCustomizer } =
|
||||
logtoConfigs;
|
||||
|
||||
router.put(
|
||||
'/configs/jwt-customizer/:tokenTypePath',
|
||||
|
@ -81,6 +82,30 @@ export default function logtoConfigJwtCustomizerRoutes<T extends AuthedRouter>(
|
|||
}
|
||||
);
|
||||
|
||||
router.patch(
|
||||
'/configs/jwt-customizer/:tokenTypePath',
|
||||
// See comments in the `PUT /configs/jwt-customizer/:tokenTypePath` route, handle the request body manually.
|
||||
koaGuard({
|
||||
params: z.object({
|
||||
tokenTypePath: z.nativeEnum(LogtoJwtTokenPath),
|
||||
}),
|
||||
body: z.unknown(),
|
||||
response: accessTokenJwtCustomizerGuard.or(clientCredentialsJwtCustomizerGuard),
|
||||
status: [200, 400, 404],
|
||||
}),
|
||||
async (ctx, next) => {
|
||||
const {
|
||||
params: { tokenTypePath },
|
||||
body: rawBody,
|
||||
} = ctx.guard;
|
||||
const { key, body } = getJwtTokenKeyAndBody(tokenTypePath, rawBody);
|
||||
|
||||
ctx.body = await updateJwtCustomizer(key, body);
|
||||
|
||||
return next();
|
||||
}
|
||||
);
|
||||
|
||||
router.get(
|
||||
'/configs/jwt-customizer',
|
||||
koaGuard({
|
||||
|
|
|
@ -164,6 +164,47 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"patch": {
|
||||
"summary": "Update JWT customizer",
|
||||
"description": "Update the JWT customizer for the given token type.",
|
||||
"parameters": [
|
||||
{
|
||||
"in": "path",
|
||||
"name": "tokenTypePath",
|
||||
"description": "The token type to update 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": {
|
||||
"200": {
|
||||
"description": "The updated JWT customizer."
|
||||
},
|
||||
"400": {
|
||||
"description": "The request body is invalid."
|
||||
}
|
||||
}
|
||||
},
|
||||
"get": {
|
||||
"summary": "Get JWT customizer",
|
||||
"description": "Get the JWT customizer for the given token type.",
|
||||
|
|
|
@ -8,8 +8,21 @@ export const accessTokenJwtCustomizerPayload = {
|
|||
...clientCredentialsJwtCustomizerPayload,
|
||||
contextSample: {
|
||||
user: {
|
||||
username: 'test',
|
||||
id: 'fake-id',
|
||||
id: '123',
|
||||
username: 'foo',
|
||||
primaryEmail: 'foo@logto.io',
|
||||
primaryPhone: '+1234567890',
|
||||
name: 'Foo Bar',
|
||||
avatar: 'https://example.com/avatar.png',
|
||||
customData: {},
|
||||
identities: {},
|
||||
profile: {},
|
||||
applicationId: 'my-app',
|
||||
ssoIdentities: [],
|
||||
mfaVerificationFactors: [],
|
||||
roles: [],
|
||||
organizations: [],
|
||||
organizationRoles: [],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
|
|
@ -52,3 +52,11 @@ export const getJwtCustomizers = async () =>
|
|||
|
||||
export const deleteJwtCustomizer = async (keyTypePath: 'access-token' | 'client-credentials') =>
|
||||
authedAdminApi.delete(`configs/jwt-customizer/${keyTypePath}`);
|
||||
|
||||
export const updateJwtCustomizer = async (
|
||||
keyTypePath: 'access-token' | 'client-credentials',
|
||||
value: unknown
|
||||
) =>
|
||||
authedAdminApi
|
||||
.patch(`configs/jwt-customizer/${keyTypePath}`, { json: value })
|
||||
.json<AccessTokenJwtCustomizer | ClientCredentialsJwtCustomizer>();
|
||||
|
|
|
@ -16,6 +16,7 @@ import {
|
|||
rotateOidcKeys,
|
||||
updateAdminConsoleConfig,
|
||||
upsertJwtCustomizer,
|
||||
updateJwtCustomizer,
|
||||
getJwtCustomizer,
|
||||
getJwtCustomizers,
|
||||
deleteJwtCustomizer,
|
||||
|
@ -153,9 +154,16 @@ describe('admin console sign-in experience', () => {
|
|||
newAccessTokenJwtCustomizerPayload
|
||||
);
|
||||
expect(updatedAccessToken).toMatchObject(newAccessTokenJwtCustomizerPayload);
|
||||
await expect(getJwtCustomizer('access-token')).resolves.toMatchObject(
|
||||
newAccessTokenJwtCustomizerPayload
|
||||
);
|
||||
const overwritePayload = { script: 'abc' };
|
||||
const updatedValue = await updateJwtCustomizer('access-token', overwritePayload);
|
||||
expect(updatedValue).toMatchObject({
|
||||
...newAccessTokenJwtCustomizerPayload,
|
||||
script: 'abc',
|
||||
});
|
||||
await expect(getJwtCustomizer('access-token')).resolves.toMatchObject({
|
||||
...newAccessTokenJwtCustomizerPayload,
|
||||
script: 'abc',
|
||||
});
|
||||
await expect(deleteJwtCustomizer('access-token')).resolves.not.toThrow();
|
||||
await expectRejects(getJwtCustomizer('access-token'), {
|
||||
code: 'entity.not_exists_with_id',
|
||||
|
@ -186,9 +194,16 @@ describe('admin console sign-in experience', () => {
|
|||
newClientCredentialsJwtCustomizerPayload
|
||||
);
|
||||
expect(updatedClientCredentials).toMatchObject(newClientCredentialsJwtCustomizerPayload);
|
||||
await expect(getJwtCustomizer('client-credentials')).resolves.toMatchObject(
|
||||
newClientCredentialsJwtCustomizerPayload
|
||||
);
|
||||
const overwritePayload = { script: 'abc' };
|
||||
const updatedValue = await updateJwtCustomizer('client-credentials', overwritePayload);
|
||||
expect(updatedValue).toMatchObject({
|
||||
...newClientCredentialsJwtCustomizerPayload,
|
||||
script: 'abc',
|
||||
});
|
||||
await expect(getJwtCustomizer('client-credentials')).resolves.toMatchObject({
|
||||
...newClientCredentialsJwtCustomizerPayload,
|
||||
script: 'abc',
|
||||
});
|
||||
await expect(deleteJwtCustomizer('client-credentials')).resolves.not.toThrow();
|
||||
await expectRejects(getJwtCustomizer('client-credentials'), {
|
||||
code: 'entity.not_exists_with_id',
|
||||
|
|
100
packages/schemas/src/types/logto-config/jwt-customizer.test.ts
Normal file
100
packages/schemas/src/types/logto-config/jwt-customizer.test.ts
Normal file
|
@ -0,0 +1,100 @@
|
|||
import { pick } from '@silverhand/essentials';
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
import {
|
||||
accessTokenJwtCustomizerGuard,
|
||||
clientCredentialsJwtCustomizerGuard,
|
||||
} from './jwt-customizer.js';
|
||||
|
||||
const allFields = ['script', 'envVars', 'contextSample', 'tokenSample'] as const;
|
||||
|
||||
const testClientCredentialsTokenPayload = {
|
||||
script: '',
|
||||
envVars: {},
|
||||
contextSample: {},
|
||||
tokenSample: {},
|
||||
};
|
||||
|
||||
const testAccessTokenPayload = {
|
||||
...testClientCredentialsTokenPayload,
|
||||
contextSample: {
|
||||
user: {
|
||||
id: '123',
|
||||
username: 'foo',
|
||||
primaryEmail: 'foo@logto.io',
|
||||
primaryPhone: '+1234567890',
|
||||
name: 'Foo Bar',
|
||||
avatar: 'https://example.com/avatar.png',
|
||||
customData: {},
|
||||
identities: {},
|
||||
profile: {},
|
||||
applicationId: 'my-app',
|
||||
ssoIdentities: [],
|
||||
mfaVerificationFactors: [],
|
||||
roles: [],
|
||||
organizations: [],
|
||||
organizationRoles: [],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
describe('test token sample guard', () => {
|
||||
it.each(allFields)('should pass guard with any of the field not specified', (droppedField) => {
|
||||
const resultAccessToken = accessTokenJwtCustomizerGuard.safeParse(
|
||||
pick(testAccessTokenPayload, ...allFields.filter((field) => field !== droppedField))
|
||||
);
|
||||
if (!resultAccessToken.success) {
|
||||
console.log('resultAccessToken.error', resultAccessToken.error);
|
||||
}
|
||||
expect(resultAccessToken.success).toBe(true);
|
||||
|
||||
const resultClientCredentials = clientCredentialsJwtCustomizerGuard.safeParse(
|
||||
pick(
|
||||
testClientCredentialsTokenPayload,
|
||||
...allFields.filter((field) => field !== droppedField)
|
||||
)
|
||||
);
|
||||
if (!resultClientCredentials.success) {
|
||||
console.log('resultClientCredentials.error', resultClientCredentials.error);
|
||||
}
|
||||
expect(resultClientCredentials.success).toBe(true);
|
||||
});
|
||||
|
||||
it.each(allFields)(
|
||||
'should pass partial guard with any of the field not specified',
|
||||
(droppedField) => {
|
||||
const resultAccessToken = accessTokenJwtCustomizerGuard
|
||||
.partial()
|
||||
.safeParse(
|
||||
pick(testAccessTokenPayload, ...allFields.filter((field) => field !== droppedField))
|
||||
);
|
||||
expect(resultAccessToken.success).toBe(true);
|
||||
|
||||
const resultClientCredentials = clientCredentialsJwtCustomizerGuard
|
||||
.partial()
|
||||
.safeParse(
|
||||
pick(
|
||||
testClientCredentialsTokenPayload,
|
||||
...allFields.filter((field) => field !== droppedField)
|
||||
)
|
||||
);
|
||||
expect(resultClientCredentials.success).toBe(true);
|
||||
}
|
||||
);
|
||||
|
||||
it('should throw when unwanted fields presented (access token)', () => {
|
||||
const result = accessTokenJwtCustomizerGuard.safeParse({
|
||||
...testAccessTokenPayload,
|
||||
abc: 'abc',
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it('should throw when unwanted fields presented (client credentials token)', () => {
|
||||
const result = clientCredentialsJwtCustomizerGuard.safeParse({
|
||||
...testClientCredentialsTokenPayload,
|
||||
abc: 'abc',
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
});
|
|
@ -40,18 +40,22 @@ export const jwtCustomizerGuard = z
|
|||
})
|
||||
.partial();
|
||||
|
||||
export const accessTokenJwtCustomizerGuard = jwtCustomizerGuard.extend({
|
||||
// Use partial token guard since users customization may not rely on all fields.
|
||||
tokenSample: accessTokenPayloadGuard.partial().optional(),
|
||||
contextSample: z.object({ user: jwtCustomizerUserContextGuard.partial() }),
|
||||
});
|
||||
export const accessTokenJwtCustomizerGuard = jwtCustomizerGuard
|
||||
.extend({
|
||||
// Use partial token guard since users customization may not rely on all fields.
|
||||
tokenSample: accessTokenPayloadGuard.partial().optional(),
|
||||
contextSample: z.object({ user: jwtCustomizerUserContextGuard.partial() }).optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
export type AccessTokenJwtCustomizer = z.infer<typeof accessTokenJwtCustomizerGuard>;
|
||||
|
||||
export const clientCredentialsJwtCustomizerGuard = jwtCustomizerGuard.extend({
|
||||
// Use partial token guard since users customization may not rely on all fields.
|
||||
tokenSample: clientCredentialsPayloadGuard.partial().optional(),
|
||||
});
|
||||
export const clientCredentialsJwtCustomizerGuard = jwtCustomizerGuard
|
||||
.extend({
|
||||
// Use partial token guard since users customization may not rely on all fields.
|
||||
tokenSample: clientCredentialsPayloadGuard.partial().optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
export type ClientCredentialsJwtCustomizer = z.infer<typeof clientCredentialsJwtCustomizerGuard>;
|
||||
|
||||
|
|
Loading…
Add table
Reference in a new issue