0
Fork 0
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:
Darcy Ye 2024-04-02 14:02:02 +08:00 committed by GitHub
commit 6cc5dd01d4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 253 additions and 18 deletions

View file

@ -37,6 +37,7 @@ const logtoConfigs: LogtoConfigLibrary = {
upsertJwtCustomizer: jest.fn(),
getJwtCustomizer: jest.fn(),
getJwtCustomizers: jest.fn(),
updateJwtCustomizer: jest.fn(),
};
describe('getAccessToken()', () => {

View file

@ -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,
};
};

View file

@ -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');

View file

@ -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,

View file

@ -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({

View file

@ -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.",

View file

@ -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: [],
},
},
};

View file

@ -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>();

View file

@ -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',

View 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);
});
});

View file

@ -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>;