0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2024-12-30 20:33:54 -05:00

feat(core): add oidc config to the response of application apis (#536)

This commit is contained in:
Xiao Yijun 2022-04-14 14:48:56 +08:00 committed by GitHub
parent 2f22a81a8f
commit d238168ebf
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
11 changed files with 119 additions and 53 deletions

View file

@ -1,4 +1,4 @@
import { Application } from '@logto/schemas'; import { Application, ApplicationDTO } from '@logto/schemas';
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { Controller, useForm } from 'react-hook-form'; import { Controller, useForm } from 'react-hook-form';
import { toast } from 'react-hot-toast'; import { toast } from 'react-hot-toast';
@ -31,26 +31,16 @@ import { noSpaceRegex } from '@/utilities/regex';
import DeleteForm from './components/DeleteForm'; import DeleteForm from './components/DeleteForm';
import * as styles from './index.module.scss'; import * as styles from './index.module.scss';
// TODO LOG-1908: OidcConfig in Application Details
type OidcConfig = {
authorization_endpoint: string;
userinfo_endpoint: string;
token_endpoint: string;
};
const ApplicationDetails = () => { const ApplicationDetails = () => {
const { id } = useParams(); const { id } = useParams();
const location = useLocation(); const location = useLocation();
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' }); const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
const { data, error, mutate } = useSWR<Application, RequestError>( const { data, error, mutate } = useSWR<ApplicationDTO, RequestError>(
id && `/api/applications/${id}` id && `/api/applications/${id}`
); );
// TODO LOG-1908: OidcConfig in Application Details
const { data: oidcConfig, error: fetchOidcConfigError } = useSWR<OidcConfig, RequestError>( const isLoading = !data && !error;
'/oidc/.well-known/openid-configuration'
);
const isLoading = !data && !error && !oidcConfig && !fetchOidcConfigError;
const [isReadmeOpen, setIsReadmeOpen] = useState(false); const [isReadmeOpen, setIsReadmeOpen] = useState(false);
@ -90,14 +80,14 @@ const ApplicationDetails = () => {
}, },
}, },
}) })
.json<Application>(); .json<ApplicationDTO>();
void mutate(updatedApplication); void mutate(updatedApplication);
toast.success(t('application_details.save_success')); toast.success(t('application_details.save_success'));
}); });
const isAdvancedSettings = location.pathname.includes('advanced-settings'); const isAdvancedSettings = location.pathname.includes('advanced-settings');
const SettingsPage = oidcConfig && ( const SettingsPage = data && (
<> <>
<FormField <FormField
isRequired isRequired
@ -113,7 +103,10 @@ const ApplicationDetails = () => {
title="admin_console.application_details.authorization_endpoint" title="admin_console.application_details.authorization_endpoint"
className={styles.textField} className={styles.textField}
> >
<CopyToClipboard className={styles.textField} value={oidcConfig.authorization_endpoint} /> <CopyToClipboard
className={styles.textField}
value={data.oidcConfig.authorizationEndpoint}
/>
</FormField> </FormField>
<FormField <FormField
isRequired isRequired
@ -170,13 +163,13 @@ const ApplicationDetails = () => {
</> </>
); );
const AdvancedSettingsPage = oidcConfig && ( const AdvancedSettingsPage = data && (
<> <>
<FormField title="admin_console.application_details.token_endpoint"> <FormField title="admin_console.application_details.token_endpoint">
<CopyToClipboard className={styles.textField} value={oidcConfig.token_endpoint} /> <CopyToClipboard className={styles.textField} value={data.oidcConfig.tokenEndpoint} />
</FormField> </FormField>
<FormField title="admin_console.application_details.user_info_endpoint"> <FormField title="admin_console.application_details.user_info_endpoint">
<CopyToClipboard className={styles.textField} value={oidcConfig.userinfo_endpoint} /> <CopyToClipboard className={styles.textField} value={data.oidcConfig.userinfoEndpoint} />
</FormField> </FormField>
</> </>
); );
@ -191,7 +184,7 @@ const ApplicationDetails = () => {
/> />
{isLoading && <div>loading</div>} {isLoading && <div>loading</div>}
{error && <div>{`error occurred: ${error.body.message}`}</div>} {error && <div>{`error occurred: ${error.body.message}`}</div>}
{data && oidcConfig && ( {data && (
<> <>
<Card className={styles.header}> <Card className={styles.header}>
<ImagePlaceholder size={76} borderRadius={16} /> <ImagePlaceholder size={76} borderRadius={16} />

View file

@ -1,4 +1,4 @@
import { Application } from '@logto/schemas'; import { ApplicationDTO } from '@logto/schemas';
import classNames from 'classnames'; import classNames from 'classnames';
import React, { useState } from 'react'; import React, { useState } from 'react';
import { toast } from 'react-hot-toast'; import { toast } from 'react-hot-toast';
@ -33,7 +33,7 @@ const Applications = () => {
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' }); const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
const [query, setQuery] = useSearchParams(); const [query, setQuery] = useSearchParams();
const pageIndex = Number(query.get('page') ?? '1'); const pageIndex = Number(query.get('page') ?? '1');
const { data, error, mutate } = useSWR<[Application[], number], RequestError>( const { data, error, mutate } = useSWR<[ApplicationDTO[], number], RequestError>(
`/api/applications?page=${pageIndex}&page_size=${pageSize}` `/api/applications?page=${pageIndex}&page_size=${pageSize}`
); );
const isLoading = !data && !error; const isLoading = !data && !error;

View file

@ -23,6 +23,7 @@
"@logto/phrases": "^0.1.0", "@logto/phrases": "^0.1.0",
"@logto/schemas": "^0.1.0", "@logto/schemas": "^0.1.0",
"@silverhand/essentials": "^1.1.0", "@silverhand/essentials": "^1.1.0",
"camelcase-keys": "^7.0.2",
"dayjs": "^1.10.5", "dayjs": "^1.10.5",
"decamelize": "^5.0.0", "decamelize": "^5.0.0",
"dotenv": "^10.0.0", "dotenv": "^10.0.0",

View file

@ -1,20 +1,29 @@
import { import {
Application, Application,
ApplicationDTO,
ApplicationType, ApplicationType,
Passcode, Passcode,
PasscodeType, PasscodeType,
Resource, Resource,
Role, Role,
Setting, Setting,
SnakeCaseOidcConfig,
UserLog, UserLog,
UserLogResult, UserLogResult,
UserLogType, UserLogType,
} from '@logto/schemas'; } from '@logto/schemas';
import camelcaseKeys from 'camelcase-keys';
export * from './connector'; export * from './connector';
export * from './sign-in-experience'; export * from './sign-in-experience';
export * from './user'; export * from './user';
export const mockOidcConfig: SnakeCaseOidcConfig = {
authorization_endpoint: 'https://logto.dev/oidc/auth',
userinfo_endpoint: 'https://logto.dev/oidc/userinfo',
token_endpoint: 'https://logto.dev/oidc/token',
};
export const mockApplication: Application = { export const mockApplication: Application = {
id: 'foo', id: 'foo',
name: 'foo', name: 'foo',
@ -32,6 +41,11 @@ export const mockApplication: Application = {
createdAt: 1_645_334_775_356, createdAt: 1_645_334_775_356,
}; };
export const mockApplicationDTO: ApplicationDTO = {
...mockApplication,
oidcConfig: camelcaseKeys(mockOidcConfig),
};
export const mockResource: Resource = { export const mockResource: Resource = {
id: 'logto_api', id: 'logto_api',
name: 'management api', name: 'management api',

View file

@ -1,6 +1,8 @@
import { Application, CreateApplication, ApplicationType } from '@logto/schemas'; import { Application, CreateApplication, ApplicationType } from '@logto/schemas';
import nock from 'nock';
import { mockApplication } from '@/__mocks__'; import { mockApplicationDTO, mockOidcConfig } from '@/__mocks__';
import { port } from '@/env/consts';
import { findApplicationById } from '@/queries/application'; import { findApplicationById } from '@/queries/application';
import { createRequester } from '@/utils/test-utils'; import { createRequester } from '@/utils/test-utils';
@ -8,22 +10,22 @@ import applicationRoutes from './application';
jest.mock('@/queries/application', () => ({ jest.mock('@/queries/application', () => ({
findTotalNumberOfApplications: jest.fn(async () => ({ count: 10 })), findTotalNumberOfApplications: jest.fn(async () => ({ count: 10 })),
findAllApplications: jest.fn(async () => [mockApplication]), findAllApplications: jest.fn(async () => [mockApplicationDTO]),
findApplicationById: jest.fn(async () => mockApplication), findApplicationById: jest.fn(async () => mockApplicationDTO),
deleteApplicationById: jest.fn(), deleteApplicationById: jest.fn(),
insertApplication: jest.fn( insertApplication: jest.fn(
async (body: CreateApplication): Promise<Application> => ({ async (body: CreateApplication): Promise<Application> => ({
...mockApplication, ...mockApplicationDTO,
...body, ...body,
oidcClientMetadata: { oidcClientMetadata: {
...mockApplication.oidcClientMetadata, ...mockApplicationDTO.oidcClientMetadata,
...body.oidcClientMetadata, ...body.oidcClientMetadata,
}, },
}) })
), ),
updateApplicationById: jest.fn( updateApplicationById: jest.fn(
async (_, data: Partial<CreateApplication>): Promise<Application> => ({ async (_, data: Partial<CreateApplication>): Promise<Application> => ({
...mockApplication, ...mockApplicationDTO,
...data, ...data,
}) })
), ),
@ -43,10 +45,15 @@ const customClientMetadata = {
describe('application route', () => { describe('application route', () => {
const applicationRequest = createRequester({ authedRoutes: applicationRoutes }); const applicationRequest = createRequester({ authedRoutes: applicationRoutes });
beforeEach(() => {
const discoveryUrl = `http://localhost:${port}/oidc/.well-known/openid-configuration`;
nock(discoveryUrl).get('').reply(200, mockOidcConfig);
});
it('GET /applications', async () => { it('GET /applications', async () => {
const response = await applicationRequest.get('/applications'); const response = await applicationRequest.get('/applications');
expect(response.status).toEqual(200); expect(response.status).toEqual(200);
expect(response.body).toEqual([mockApplication]); expect(response.body).toEqual([mockApplicationDTO]);
expect(response.header).toHaveProperty('total-number', '10'); expect(response.header).toHaveProperty('total-number', '10');
}); });
@ -60,7 +67,7 @@ describe('application route', () => {
expect(response.status).toEqual(200); expect(response.status).toEqual(200);
expect(response.body).toEqual({ expect(response.body).toEqual({
...mockApplication, ...mockApplicationDTO,
id: 'randomId', id: 'randomId',
name, name,
type, type,
@ -98,7 +105,7 @@ describe('application route', () => {
const response = await applicationRequest.get('/applications/foo'); const response = await applicationRequest.get('/applications/foo');
expect(response.status).toEqual(200); expect(response.status).toEqual(200);
expect(response.body).toEqual(mockApplication); expect(response.body).toEqual(mockApplicationDTO);
}); });
it('PATCH /applications/:applicationId', async () => { it('PATCH /applications/:applicationId', async () => {
@ -109,7 +116,7 @@ describe('application route', () => {
.send({ name, customClientMetadata }); .send({ name, customClientMetadata });
expect(response.status).toEqual(200); expect(response.status).toEqual(200);
expect(response.body).toEqual({ ...mockApplication, name, customClientMetadata }); expect(response.body).toEqual({ ...mockApplicationDTO, name, customClientMetadata });
}); });
it('PATCH /applications/:applicationId expect to throw with invalid properties', async () => { it('PATCH /applications/:applicationId expect to throw with invalid properties', async () => {

View file

@ -1,6 +1,9 @@
import { Applications } from '@logto/schemas'; import { Applications, SnakeCaseOidcConfig } from '@logto/schemas';
import camelcaseKeys from 'camelcase-keys';
import got from 'got';
import { object, string } from 'zod'; import { object, string } from 'zod';
import { port } from '@/env/consts';
import koaGuard from '@/middleware/koa-guard'; import koaGuard from '@/middleware/koa-guard';
import koaPagination from '@/middleware/koa-pagination'; import koaPagination from '@/middleware/koa-pagination';
import { buildOidcClientMetadata } from '@/oidc/utils'; import { buildOidcClientMetadata } from '@/oidc/utils';
@ -18,18 +21,24 @@ import { AuthedRouter } from './types';
const applicationId = buildIdGenerator(21); const applicationId = buildIdGenerator(21);
const discoveryUrl = `http://localhost:${port}/oidc/.well-known/openid-configuration`;
export default function applicationRoutes<T extends AuthedRouter>(router: T) { export default function applicationRoutes<T extends AuthedRouter>(router: T) {
router.get('/applications', koaPagination(), async (ctx, next) => { router.get('/applications', koaPagination(), async (ctx, next) => {
const { limit, offset } = ctx.pagination; const { limit, offset } = ctx.pagination;
const [{ count }, applications] = await Promise.all([ const [{ count }, applications, oidcConfig] = await Promise.all([
findTotalNumberOfApplications(), findTotalNumberOfApplications(),
findAllApplications(limit, offset), findAllApplications(limit, offset),
got(discoveryUrl).json<SnakeCaseOidcConfig>(),
]); ]);
// Return totalCount to pagination middleware // Return totalCount to pagination middleware
ctx.pagination.totalCount = count; ctx.pagination.totalCount = count;
ctx.body = applications; ctx.body = applications.map((application) => ({
...application,
oidcConfig: camelcaseKeys(oidcConfig),
}));
return next(); return next();
}); });
@ -45,13 +54,21 @@ export default function applicationRoutes<T extends AuthedRouter>(router: T) {
async (ctx, next) => { async (ctx, next) => {
const { name, type, oidcClientMetadata, customClientMetadata } = ctx.guard.body; const { name, type, oidcClientMetadata, customClientMetadata } = ctx.guard.body;
ctx.body = await insertApplication({ const [application, oidcConfig] = await Promise.all([
insertApplication({
id: applicationId(), id: applicationId(),
type, type,
name, name,
oidcClientMetadata: buildOidcClientMetadata(oidcClientMetadata), oidcClientMetadata: buildOidcClientMetadata(oidcClientMetadata),
customClientMetadata, customClientMetadata,
}); }),
got(discoveryUrl).json<SnakeCaseOidcConfig>(),
]);
ctx.body = {
...application,
oidcConfig: camelcaseKeys(oidcConfig),
};
return next(); return next();
} }
@ -67,7 +84,15 @@ export default function applicationRoutes<T extends AuthedRouter>(router: T) {
params: { id }, params: { id },
} = ctx.guard; } = ctx.guard;
ctx.body = await findApplicationById(id); const [application, oidcConfig] = await Promise.all([
findApplicationById(id),
got(discoveryUrl).json<SnakeCaseOidcConfig>(),
]);
ctx.body = {
...application,
oidcConfig: camelcaseKeys(oidcConfig),
};
return next(); return next();
} }
@ -85,9 +110,17 @@ export default function applicationRoutes<T extends AuthedRouter>(router: T) {
body, body,
} = ctx.guard; } = ctx.guard;
ctx.body = await updateApplicationById(id, { const [application, oidcConfig] = await Promise.all([
updateApplicationById(id, {
...body, ...body,
}); }),
got(discoveryUrl).json<SnakeCaseOidcConfig>(),
]);
ctx.body = {
...application,
oidcConfig: camelcaseKeys(oidcConfig),
};
return next(); return next();
} }

View file

@ -23,7 +23,7 @@
}, },
"devDependencies": { "devDependencies": {
"@silverhand/eslint-config": "^0.10.2", "@silverhand/eslint-config": "^0.10.2",
"@silverhand/essentials": "^1.1.0", "@silverhand/essentials": "^1.1.6",
"@silverhand/ts-config": "^0.10.2", "@silverhand/ts-config": "^0.10.2",
"@types/lodash.uniq": "^4.5.6", "@types/lodash.uniq": "^4.5.6",
"@types/node": "14", "@types/node": "14",

View file

@ -0,0 +1,6 @@
import { Application } from '../db-entries';
import { OidcConfig } from './oidc-config';
export interface ApplicationDTO extends Application {
oidcConfig: OidcConfig;
}

View file

@ -1,2 +1,4 @@
export * from './user'; export * from './user';
export * from './connector'; export * from './connector';
export * from './oidc-config';
export * from './application';

View file

@ -0,0 +1,9 @@
import { KeysToCamelCase } from '@silverhand/essentials';
export type SnakeCaseOidcConfig = {
authorization_endpoint: string;
userinfo_endpoint: string;
token_endpoint: string;
};
export type OidcConfig = KeysToCamelCase<SnakeCaseOidcConfig>;

View file

@ -165,6 +165,7 @@ importers:
'@types/node': ^16.3.1 '@types/node': ^16.3.1
'@types/oidc-provider': ^7.8.0 '@types/oidc-provider': ^7.8.0
'@types/supertest': ^2.0.11 '@types/supertest': ^2.0.11
camelcase-keys: ^7.0.2
copyfiles: ^2.4.1 copyfiles: ^2.4.1
dayjs: ^1.10.5 dayjs: ^1.10.5
decamelize: ^5.0.0 decamelize: ^5.0.0
@ -206,6 +207,7 @@ importers:
'@logto/phrases': link:../phrases '@logto/phrases': link:../phrases
'@logto/schemas': link:../schemas '@logto/schemas': link:../schemas
'@silverhand/essentials': 1.1.2 '@silverhand/essentials': 1.1.2
camelcase-keys: 7.0.2
dayjs: 1.10.7 dayjs: 1.10.7
decamelize: 5.0.1 decamelize: 5.0.1
dotenv: 10.0.0 dotenv: 10.0.0
@ -315,7 +317,7 @@ importers:
specifiers: specifiers:
'@logto/phrases': ^0.1.0 '@logto/phrases': ^0.1.0
'@silverhand/eslint-config': ^0.10.2 '@silverhand/eslint-config': ^0.10.2
'@silverhand/essentials': ^1.1.0 '@silverhand/essentials': ^1.1.6
'@silverhand/ts-config': ^0.10.2 '@silverhand/ts-config': ^0.10.2
'@types/lodash.uniq': ^4.5.6 '@types/lodash.uniq': ^4.5.6
'@types/node': '14' '@types/node': '14'
@ -334,7 +336,7 @@ importers:
zod: 3.14.3 zod: 3.14.3
devDependencies: devDependencies:
'@silverhand/eslint-config': 0.10.2_3a533fa6cc3da0cf8525ef55d41c4384 '@silverhand/eslint-config': 0.10.2_3a533fa6cc3da0cf8525ef55d41c4384
'@silverhand/essentials': 1.1.2 '@silverhand/essentials': 1.1.7
'@silverhand/ts-config': 0.10.2_typescript@4.6.2 '@silverhand/ts-config': 0.10.2_typescript@4.6.2
'@types/lodash.uniq': 4.5.6 '@types/lodash.uniq': 4.5.6
'@types/node': 14.18.0 '@types/node': 14.18.0
@ -5260,6 +5262,7 @@ packages:
dependencies: dependencies:
lodash.orderby: 4.6.0 lodash.orderby: 4.6.0
lodash.pick: 4.4.0 lodash.pick: 4.4.0
dev: false
/@silverhand/essentials/1.1.4: /@silverhand/essentials/1.1.4:
resolution: {integrity: sha512-5pHjIz42CjILcqGWhmfP7/RCbmlWIWmj0H3RMJDGW3QKZyNkWawG6gKwtEQ75N0MoZOzXjNE4HD4DK3moPa5sg==} resolution: {integrity: sha512-5pHjIz42CjILcqGWhmfP7/RCbmlWIWmj0H3RMJDGW3QKZyNkWawG6gKwtEQ75N0MoZOzXjNE4HD4DK3moPa5sg==}
@ -5283,7 +5286,6 @@ packages:
dependencies: dependencies:
lodash.orderby: 4.6.0 lodash.orderby: 4.6.0
lodash.pick: 4.4.0 lodash.pick: 4.4.0
dev: false
/@silverhand/ts-config-react/0.10.3_typescript@4.6.2: /@silverhand/ts-config-react/0.10.3_typescript@4.6.2:
resolution: {integrity: sha512-xGOwcw1HTixfP3PSSdJT3leGnlUV0dLna9xp58bDDLul7UCnIn+PNp1VNJxUZ/HvtKbV4ZSYdGsGE6Xqmwn7Ag==} resolution: {integrity: sha512-xGOwcw1HTixfP3PSSdJT3leGnlUV0dLna9xp58bDDLul7UCnIn+PNp1VNJxUZ/HvtKbV4ZSYdGsGE6Xqmwn7Ag==}
@ -7374,7 +7376,6 @@ packages:
/camelcase/6.3.0: /camelcase/6.3.0:
resolution: {integrity: sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==} resolution: {integrity: sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==}
engines: {node: '>=10'} engines: {node: '>=10'}
dev: false
/caniuse-api/3.0.0: /caniuse-api/3.0.0:
resolution: {integrity: sha512-bsTwuIg/BZZK/vreVTYYbSWoe2F+71P7K5QGEX+pT250DZbfU1MQ5prOKpPR+LL6uWKK3KMwMCAS74QB3Um1uw==} resolution: {integrity: sha512-bsTwuIg/BZZK/vreVTYYbSWoe2F+71P7K5QGEX+pT250DZbfU1MQ5prOKpPR+LL6uWKK3KMwMCAS74QB3Um1uw==}
@ -11898,7 +11899,7 @@ packages:
engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0}
dependencies: dependencies:
'@jest/types': 27.5.1 '@jest/types': 27.5.1
camelcase: 6.2.1 camelcase: 6.3.0
chalk: 4.1.2 chalk: 4.1.2
jest-get-type: 27.5.1 jest-get-type: 27.5.1
leven: 3.1.0 leven: 3.1.0