feat: oidc (#1126)

* feat: oidc

* feat: oidc remove comments

* feat: oidc add shared type

* feat: oidc add id_token sign alg

* feat: oidc i18n & batch delete

* feat: oidc i18n

* feat: oidc import fix

* feat: oidc saml list fix

* feat: oidc i18n move to plugin

* feat: oidc cr fix

* feat: oidc cr fix

* feat: oidc cr fix

* feat: oidc fix nonce value

* feat: oidc page extension fix

* feat: oidc remove canceltoken

Co-authored-by: chenos <chenlinxh@gmail.com>
This commit is contained in:
anuoua 2022-11-29 23:18:21 +08:00 committed by GitHub
parent 78fe77e2cf
commit 1ac0032e5c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
33 changed files with 1128 additions and 10 deletions

View File

@ -0,0 +1 @@
export { default } from '@nocobase/plugin-oidc/client';

View File

@ -18,18 +18,19 @@ import {
RemoteRouteSwitchProvider,
RouteSchemaComponent,
RouteSwitch,
useRoutes
useRoutes,
} from '../route-switch';
import {
AntdSchemaComponentProvider,
DesignableSwitch,
MenuItemInitializers,
SchemaComponentProvider
SchemaComponentProvider,
} from '../schema-component';
import { SchemaInitializerProvider } from '../schema-initializer';
import { BlockTemplateDetails, BlockTemplatePage, SchemaTemplateShortcut } from '../schema-templates';
import { SystemSettingsProvider, SystemSettingsShortcut } from '../system-settings';
import { SigninPage, SignupPage } from '../user';
import { SigninPageExtensionProvider } from '../user/SigninPageExtension';
import { compose } from './compose';
export interface ApplicationOptions {
@ -112,6 +113,7 @@ export class Application {
});
this.use(BlockSchemaComponentProvider);
this.use(AntdSchemaComponentProvider);
this.use(SigninPageExtensionProvider);
this.use(ACLProvider);
this.use(RemoteDocumentTitleProvider);
this.use(PMProvider);

View File

@ -1,9 +1,11 @@
import { ISchema, useForm } from '@formily/react';
import { Tabs } from 'antd';
import React, { useCallback } from 'react';
import { Space, Tabs } from 'antd';
import React, { useCallback, useContext } from 'react';
import { css } from '@emotion/css';
import { useTranslation } from 'react-i18next';
import { Link, useHistory, useLocation } from 'react-router-dom';
import { SchemaComponent, useAPIClient, useCurrentDocumentTitle, useSystemSettings } from '..';
import { useSigninPageExtension } from './SigninPageExtension';
import VerificationCode from './VerificationCode';
const passwordForm: ISchema = {
@ -47,7 +49,7 @@ const passwordForm: ISchema = {
},
};
function useRedirect(next = '/admin') {
export function useRedirect(next = '/admin') {
const location = useLocation<any>();
const history = useHistory();
const redirect = location?.['query']?.redirect;
@ -130,10 +132,16 @@ export const SigninPage = (props: SigninPageProps) => {
const { t } = useTranslation();
useCurrentDocumentTitle('Signin');
const ctx = useSystemSettings();
const signinExtension = useSigninPageExtension();
const { allowSignUp, smsAuthEnabled } = ctx?.data?.data || {};
const { schema, components, scope } = props;
return (
<div>
<Space
direction="vertical"
className={css`
display: flex;
`}
>
{smsAuthEnabled ? (
<Tabs defaultActiveKey="password">
<Tabs.TabPane tab={t('Sign in via account')} key="password">
@ -157,11 +165,12 @@ export const SigninPage = (props: SigninPageProps) => {
schema={schema || passwordForm}
/>
)}
<div>{signinExtension}</div>
{allowSignUp && (
<div>
<Link to="/signup">{t('Create an account')}</Link>
</div>
)}
</div>
</Space>
);
};

View File

@ -0,0 +1,24 @@
import React, { createContext, FunctionComponent, useContext } from 'react';
export interface SigninPageExtensionContextValue {
components: FunctionComponent[];
}
export const SigninPageExtensionContext = createContext<SigninPageExtensionContextValue>({ components: [] });
export const useSigninPageExtension = () => {
const { components } = useContext(SigninPageExtensionContext);
return components.map((component, index) => React.createElement(component, { key: index }));
};
export const SigninPageExtensionProvider = (props: { component: FunctionComponent; children: JSX.Element }) => {
const { components } = useContext(SigninPageExtensionContext);
const list = props.component ? [...components, props.component] : components;
return (
<SigninPageExtensionContext.Provider value={{ components: list }}>
{props.children}
</SigninPageExtensionContext.Provider>
);
};

View File

@ -2,4 +2,4 @@ export * from './CurrentUser';
export * from './CurrentUserProvider';
export * from './SigninPage';
export * from './SignupPage';
export * from './SigninPageExtension';

View File

@ -66,6 +66,7 @@ export class PluginManager {
if (options?.method !== 'install' || options.reload) {
await this.repository.load();
}
this.app.acl.allow('applicationPlugins', 'list');
});
this.addStaticMultiple(options.plugins);
}
@ -329,7 +330,9 @@ export class PluginManager {
}
static getPluginPkgPrefix() {
return (process.env.PLUGIN_PACKAGE_PREFIX || '@nocobase/plugin-,@nocobase/preset-,@nocobase/plugin-pro-').split(',');
return (process.env.PLUGIN_PACKAGE_PREFIX || '@nocobase/plugin-,@nocobase/preset-,@nocobase/plugin-pro-').split(
',',
);
}
static async findPackage(name: string) {

4
packages/plugins/oidc/client.d.ts vendored Executable file
View File

@ -0,0 +1,4 @@
// @ts-nocheck
export * from './lib/client';
export { default } from './lib/client';

30
packages/plugins/oidc/client.js Executable file
View File

@ -0,0 +1,30 @@
"use strict";
function _getRequireWildcardCache(nodeInterop) { if (typeof WeakMap !== "function") return null; var cacheBabelInterop = new WeakMap(); var cacheNodeInterop = new WeakMap(); return (_getRequireWildcardCache = function _getRequireWildcardCache(nodeInterop) { return nodeInterop ? cacheNodeInterop : cacheBabelInterop; })(nodeInterop); }
function _interopRequireWildcard(obj, nodeInterop) { if (!nodeInterop && obj && obj.__esModule) { return obj; } if (obj === null || typeof obj !== "object" && typeof obj !== "function") { return { default: obj }; } var cache = _getRequireWildcardCache(nodeInterop); if (cache && cache.has(obj)) { return cache.get(obj); } var newObj = {}; var hasPropertyDescriptor = Object.defineProperty && Object.getOwnPropertyDescriptor; for (var key in obj) { if (key !== "default" && Object.prototype.hasOwnProperty.call(obj, key)) { var desc = hasPropertyDescriptor ? Object.getOwnPropertyDescriptor(obj, key) : null; if (desc && (desc.get || desc.set)) { Object.defineProperty(newObj, key, desc); } else { newObj[key] = obj[key]; } } } newObj.default = obj; if (cache) { cache.set(obj, newObj); } return newObj; }
var _index = _interopRequireWildcard(require("./lib/client"));
Object.defineProperty(exports, "__esModule", {
value: true
});
var _exportNames = {};
Object.defineProperty(exports, "default", {
enumerable: true,
get: function get() {
return _index.default;
}
});
Object.keys(_index).forEach(function (key) {
if (key === "default" || key === "__esModule") return;
if (Object.prototype.hasOwnProperty.call(_exportNames, key)) return;
if (key in exports && exports[key] === _index[key]) return;
Object.defineProperty(exports, key, {
enumerable: true,
get: function get() {
return _index[key];
}
});
});

View File

@ -0,0 +1,12 @@
{
"name": "@nocobase/plugin-oidc",
"version": "0.8.0-alpha.13",
"main": "lib/server/index.js",
"devDependencies": {
"@nocobase/server": "0.8.0-alpha.13",
"@nocobase/test": "0.8.0-alpha.13"
},
"dependencies": {
"openid-client": "^5.3.0"
}
}

4
packages/plugins/oidc/server.d.ts vendored Executable file
View File

@ -0,0 +1,4 @@
// @ts-nocheck
export * from './lib/server';
export { default } from './lib/server';

30
packages/plugins/oidc/server.js Executable file
View File

@ -0,0 +1,30 @@
"use strict";
function _getRequireWildcardCache(nodeInterop) { if (typeof WeakMap !== "function") return null; var cacheBabelInterop = new WeakMap(); var cacheNodeInterop = new WeakMap(); return (_getRequireWildcardCache = function _getRequireWildcardCache(nodeInterop) { return nodeInterop ? cacheNodeInterop : cacheBabelInterop; })(nodeInterop); }
function _interopRequireWildcard(obj, nodeInterop) { if (!nodeInterop && obj && obj.__esModule) { return obj; } if (obj === null || typeof obj !== "object" && typeof obj !== "function") { return { default: obj }; } var cache = _getRequireWildcardCache(nodeInterop); if (cache && cache.has(obj)) { return cache.get(obj); } var newObj = {}; var hasPropertyDescriptor = Object.defineProperty && Object.getOwnPropertyDescriptor; for (var key in obj) { if (key !== "default" && Object.prototype.hasOwnProperty.call(obj, key)) { var desc = hasPropertyDescriptor ? Object.getOwnPropertyDescriptor(obj, key) : null; if (desc && (desc.get || desc.set)) { Object.defineProperty(newObj, key, desc); } else { newObj[key] = obj[key]; } } } newObj.default = obj; if (cache) { cache.set(obj, newObj); } return newObj; }
var _index = _interopRequireWildcard(require("./lib/server"));
Object.defineProperty(exports, "__esModule", {
value: true
});
var _exportNames = {};
Object.defineProperty(exports, "default", {
enumerable: true,
get: function get() {
return _index.default;
}
});
Object.keys(_index).forEach(function (key) {
if (key === "default" || key === "__esModule") return;
if (Object.prototype.hasOwnProperty.call(_exportNames, key)) return;
if (key in exports && exports[key] === _index[key]) return;
Object.defineProperty(exports, key, {
enumerable: true,
get: function get() {
return _index[key];
}
});
});

View File

@ -0,0 +1,101 @@
import React, { useEffect, useState, useRef } from 'react';
import { Button, Space } from 'antd';
import { LoginOutlined } from '@ant-design/icons';
import { useMemoizedFn } from 'ahooks';
import { css } from '@emotion/css';
import { useAPIClient, useRedirect } from '@nocobase/client';
export interface OIDCProvider {
clientId: string;
title: string;
authorizeUrl: string;
}
export const OIDCList = () => {
const [list, setList] = useState<OIDCProvider[]>([]);
const [windowHandler, setWindowHandler] = useState<Window | undefined>();
const api = useAPIClient();
const redirect = useRedirect();
const getOidcList = async () => {
const { data: pluginsRes } = await api.request({
url: 'app:getPlugins',
});
if (!(pluginsRes.data as string[]).includes('oidc')) return;
const { data: providersRes } = await api.request({
url: 'oidcProviders:list',
params: {
filter: {
enabled: true,
},
},
});
setList(providersRes.data);
};
/**
*
*/
const handleOpen = useMemoizedFn(async (item: OIDCProvider) => {
const response = await api.request({
method: 'post',
url: 'oidc:getAuthUrl',
data: {
clientId: item.clientId,
},
});
const authUrl = response?.data?.data;
const { width, height } = screen;
const win = window.open(
authUrl,
'_blank',
`width=800,height=600,left=${(width - 800) / 2},top=${
(height - 600) / 2
},toolbar=no,menubar=no,location=no,status=no`,
);
setWindowHandler(win);
});
/**
*
*/
const handleOIDCLogin = useMemoizedFn(async (event: MessageEvent) => {
await api.auth.signIn(event.data, 'oidc');
windowHandler.close();
setWindowHandler(undefined);
redirect();
});
/**
*
*/
useEffect(() => {
if (!windowHandler) return;
window.addEventListener('message', handleOIDCLogin);
return () => {
window.removeEventListener('message', handleOIDCLogin);
};
}, [windowHandler]);
useEffect(() => {
getOidcList();
}, []);
return (
<Space
direction="vertical"
className={css`
display: flex;
`}
>
{list.map((item) => (
<Button shape="round" block key={item.clientId} icon={<LoginOutlined />} onClick={() => handleOpen(item)}>
OIDC: {item.title}
</Button>
))}
</Space>
);
};

View File

@ -0,0 +1,23 @@
import React from 'react';
import { uid } from '@formily/shared';
import { SchemaComponent, useRecord } from '@nocobase/client';
import { Card } from 'antd';
import { oidcSchema } from './schemas/oidc';
import { RedirectURLInput } from './RedirectURLInput';
import { useOidcTranslation } from './locale';
const schema = {
type: 'object',
properties: {
[uid()]: oidcSchema,
},
};
export const OIDCPanel = () => {
const { t } = useOidcTranslation();
return (
<Card bordered={false}>
<SchemaComponent components={{ RedirectURLInput }} schema={schema} scope={{ t }} />
</Card>
);
};

View File

@ -0,0 +1,27 @@
import React from 'react';
import { FormLayout } from '@formily/antd';
import { Field } from '@formily/core';
import { observer, useField, useForm } from '@formily/react';
import { useEffect } from 'react';
import { Input, useRecord } from '@nocobase/client';
export const RedirectURLInput = observer(() => {
const form = useForm();
const field = useField<Field>();
const record = useRecord();
const clientId = form.values.clientId ?? record.clientId;
useEffect(() => {
const { protocol, host } = window.location;
field.setValue(`${protocol}//${host}/api/oidc:redirect?clientId=${clientId}`);
}, [clientId]);
return (
<div>
<FormLayout layout={'vertical'}>
<Input disabled value={field.value} />
</FormLayout>
</div>
);
});

View File

@ -0,0 +1,38 @@
import { PluginManagerContext, SettingsCenterProvider, SigninPageExtensionProvider } from '@nocobase/client';
import React, { useContext } from 'react';
import { useOidcTranslation } from './locale';
import { OIDCList } from './OIDCList';
import { OIDCPanel } from './OIDCPanel';
export default function (props) {
const { t } = useOidcTranslation();
const ctx = useContext(PluginManagerContext);
return (
<SigninPageExtensionProvider component={OIDCList}>
<SettingsCenterProvider
settings={{
'oidc-manager': {
title: t('OIDC manager'),
icon: 'FileOutlined',
tabs: {
storages: {
title: t('OIDC Providers'),
component: OIDCPanel,
},
},
},
}}
>
<PluginManagerContext.Provider
value={{
components: {
...ctx?.components,
},
}}
>
{props.children}
</PluginManagerContext.Provider>
</SettingsCenterProvider>
</SigninPageExtensionProvider>
);
}

View File

@ -0,0 +1,20 @@
export default {
Enable: 'Enable',
Issuer: 'Issuer',
'OIDC manager': 'OIDC manager',
'OIDC Providers': 'OIDC Providers',
'Provider name': 'Name',
'Client id': 'Client id',
'Client secret': 'Client secret',
'Openid configuration': 'Openid configuration',
'Authorization endpoint': 'Authorization endpoint',
'Access token endpoint': 'Access token endpoint',
'JWKS endpoint': 'JWKS endpoint',
'Userinfo endpoint': 'Userinfo endpoint',
'Redirect url': 'Redirect url',
'Logout endpoint': 'Logout endpoint',
'Id token sign alg': 'Id token sign alg',
'Add provider': 'Add provider',
'Edit provider': 'Edit provider',
'Delete provider': 'Delete provider',
};

View File

@ -0,0 +1,24 @@
import { useTranslation } from 'react-i18next';
import { i18n } from '@nocobase/client';
import zhCN from './zh-CN';
import enUS from './en-US';
import jaJP from './ja-JP';
import ruRU from './ru-RU';
import trTR from './tr-TR';
export const NAMESPACE = 'workflow';
i18n.addResources('zh-CN', NAMESPACE, zhCN);
i18n.addResources('en-US', NAMESPACE, enUS);
i18n.addResources('ja-JP', NAMESPACE, jaJP);
i18n.addResources('ru-RU', NAMESPACE, ruRU);
i18n.addResources('tr-TR', NAMESPACE, trTR);
export function lang(key: string) {
return i18n.t(key, { ns: NAMESPACE });
}
export function useOidcTranslation() {
return useTranslation(NAMESPACE);
}

View File

@ -0,0 +1 @@
export default {};

View File

@ -0,0 +1 @@
export default {};

View File

@ -0,0 +1 @@
export default {};

View File

@ -0,0 +1,23 @@
export default {
Issuer: 'Issuer',
Enable: '启用',
Actions: '操作',
Delete: '删除',
Edit: '编辑',
'OIDC manager': 'OIDC 管理',
'OIDC Providers': 'OIDC 身份提供者',
'Provider name': '名称',
'Client id': '客户端 id',
'Client secret': '客户端 secret',
'Openid configuration': '服务发现地址',
'Authorization endpoint': '授权端点',
'Access token endpoint': '令牌端点',
'JWKS endpoint': 'JWKS 公钥端点',
'Userinfo endpoint': '用户信息端点',
'Redirect url': '重定向地址',
'Logout endpoint': '登出端点',
'Id token sign alg': 'Id token 签名算法',
'Add provider': '添加身份提供者',
'Edit provider': '编辑身份提供者',
'Delete provider': '删除身份提供者',
};

View File

@ -0,0 +1,451 @@
import { ISchema } from '@formily/react';
import { uid } from '@formily/shared';
import { useActionContext, useRequest } from '@nocobase/client';
import { IDTOKEN_SIGN_ALG } from '../../server/shared/types';
const collection = {
name: 'oidcProviders',
fields: [
{
type: 'string',
name: 'title',
interface: 'input',
uiSchema: {
title: '{{t("Provider name")}}',
type: 'string',
'x-component': 'Input',
required: true,
} as ISchema,
},
{
type: 'string',
name: 'clientId',
interface: 'input',
uiSchema: {
title: '{{t("Client id")}}',
type: 'string',
'x-component': 'Input',
required: true,
} as ISchema,
},
{
type: 'string',
name: 'clientSecret',
interface: 'input',
uiSchema: {
title: '{{t("Client secret")}}',
type: 'string',
'x-component': 'Input',
required: true,
} as ISchema,
},
{
type: 'string',
name: 'issuer',
interface: 'input',
uiSchema: {
title: '{{t("Issuer")}}',
type: 'string',
'x-component': 'Input',
required: true,
} as ISchema,
},
{
type: 'string',
name: 'openidConfiguration',
interface: 'input',
uiSchema: {
title: '{{t("Openid configuration")}}',
type: 'string',
'x-component': 'Input',
required: true,
} as ISchema,
},
{
type: 'string',
name: 'authorizeUrl',
interface: 'input',
uiSchema: {
title: '{{t("Authorization endpoint")}}',
type: 'string',
'x-component': 'Input',
required: true,
} as ISchema,
},
{
type: 'string',
name: 'tokenUrl',
interface: 'input',
uiSchema: {
title: '{{t("Access token endpoint")}}',
type: 'string',
'x-component': 'Input',
required: true,
} as ISchema,
},
{
type: 'string',
name: 'jwksUrl',
interface: 'input',
uiSchema: {
title: '{{t("JWKS endpoint")}}',
type: 'string',
'x-component': 'Input',
required: true,
} as ISchema,
},
{
type: 'string',
name: 'userinfoUrl',
interface: 'input',
uiSchema: {
title: '{{t("Userinfo endpoint")}}',
type: 'string',
'x-component': 'Input',
required: true,
} as ISchema,
},
{
type: 'string',
name: 'logoutUrl',
interface: 'input',
uiSchema: {
title: '{{t("Logout endpoint")}}',
type: 'string',
'x-component': 'Input',
required: true,
} as ISchema,
},
{
type: 'string',
name: 'idTokenSignAlg',
interface: 'select',
uiSchema: {
title: '{{ t("Id token sign alg") }}',
type: 'string',
'x-component': 'Select',
'x-component-props': {
showSearch: false,
filterSort: false,
options: [
{
label: IDTOKEN_SIGN_ALG.RS256,
value: IDTOKEN_SIGN_ALG.RS256,
},
{
label: IDTOKEN_SIGN_ALG.HS256,
value: IDTOKEN_SIGN_ALG.HS256,
},
],
},
required: true,
} as ISchema,
},
{
type: 'string',
name: 'redirectUrl',
interface: 'input',
uiSchema: {
title: '{{t("Redirect url")}}',
type: 'string',
'x-component': 'RedirectURLInput',
} as ISchema,
},
{
type: 'boolean',
name: 'enabled',
interface: 'boolean',
uiSchema: {
title: '{{t("Enable")}}',
type: 'boolean',
'x-component': 'Checkbox',
} as ISchema,
},
],
};
export const formProperties = {
title: {
'x-component': 'CollectionField',
'x-decorator': 'FormItem',
},
clientId: {
'x-component': 'CollectionField',
'x-decorator': 'FormItem',
},
clientSecret: {
'x-component': 'CollectionField',
'x-decorator': 'FormItem',
},
issuer: {
'x-component': 'CollectionField',
'x-decorator': 'FormItem',
},
openidConfiguration: {
'x-component': 'CollectionField',
'x-decorator': 'FormItem',
},
authorizeUrl: {
'x-component': 'CollectionField',
'x-decorator': 'FormItem',
},
tokenUrl: {
'x-component': 'CollectionField',
'x-decorator': 'FormItem',
},
jwksUrl: {
'x-component': 'CollectionField',
'x-decorator': 'FormItem',
},
userinfoUrl: {
'x-component': 'CollectionField',
'x-decorator': 'FormItem',
},
logoutUrl: {
'x-component': 'CollectionField',
'x-decorator': 'FormItem',
},
idTokenSignAlg: {
'x-component': 'CollectionField',
'x-decorator': 'FormItem',
},
redirectUrl: {
'x-component': 'CollectionField',
'x-decorator': 'FormItem',
},
enabled: {
'x-component': 'CollectionField',
'x-decorator': 'FormItem',
title: '',
'x-content': '{{t("Enable")}}',
},
};
export const oidcSchema: ISchema = {
type: 'object',
properties: {
block1: {
type: 'void',
'x-decorator': 'ResourceActionProvider',
'x-decorator-props': {
collection,
resourceName: 'oidcProviders',
request: {
resource: 'oidcProviders',
action: 'list',
params: {
pageSize: 50,
sort: ['id'],
appends: [],
},
},
},
'x-component': 'CollectionProvider',
'x-component-props': {
collection,
},
properties: {
actions: {
type: 'void',
'x-component': 'ActionBar',
'x-component-props': {
style: {
marginBottom: 16,
},
},
properties: {
delete: {
type: 'void',
title: '{{ t("Delete") }}',
'x-component': 'Action',
'x-component-props': {
useAction: '{{ cm.useBulkDestroyAction }}',
confirm: {
title: "{{t('Delete provider')}}",
content: "{{t('Are you sure you want to delete it?')}}",
},
},
},
create: {
type: 'void',
title: '{{t("Add provider")}}',
'x-component': 'Action',
'x-component-props': {
type: 'primary',
},
properties: {
drawer: {
type: 'void',
'x-component': 'Action.Drawer',
'x-decorator': 'Form',
'x-decorator-props': {
useValues(options) {
const ctx = useActionContext();
// 初始化数据
return useRequest(
() =>
Promise.resolve({
data: {
enable: true,
idTokenSignAlg: IDTOKEN_SIGN_ALG.RS256,
},
}),
{ ...options, refreshDeps: [ctx.visible] },
);
},
},
title: '{{t("Add provider")}}',
properties: {
...formProperties,
footer: {
type: 'void',
'x-component': 'Action.Drawer.Footer',
properties: {
cancel: {
title: '{{t("Cancel")}}',
'x-component': 'Action',
'x-component-props': {
useAction: '{{ cm.useCancelAction }}',
},
},
submit: {
title: '{{t("Submit")}}',
'x-component': 'Action',
'x-component-props': {
type: 'primary',
useAction: '{{ cm.useCreateAction }}',
},
},
},
},
},
},
},
},
},
},
table: {
type: 'void',
'x-uid': 'input',
'x-component': 'Table.Void',
'x-component-props': {
rowKey: 'id',
rowSelection: {
type: 'checkbox',
},
useDataSource: '{{ cm.useDataSourceFromRAC }}',
},
properties: {
column1: {
type: 'void',
'x-decorator': 'Table.Column.Decorator',
'x-component': 'Table.Column',
properties: {
title: {
type: 'string',
'x-component': 'CollectionField',
'x-read-pretty': true,
},
},
},
column2: {
type: 'void',
'x-decorator': 'Table.Column.Decorator',
'x-component': 'Table.Column',
properties: {
redirectUrl: {
type: 'string',
'x-component': 'CollectionField',
'x-read-pretty': true,
},
},
},
column3: {
type: 'void',
'x-decorator': 'Table.Column.Decorator',
'x-component': 'Table.Column',
properties: {
enabled: {
type: 'boolean',
'x-component': 'CollectionField',
'x-read-pretty': true,
},
},
},
column4: {
type: 'void',
title: '{{t("Actions")}}',
'x-component': 'Table.Column',
properties: {
actions: {
type: 'void',
'x-component': 'Space',
'x-component-props': {
split: '|',
},
properties: {
update: {
type: 'void',
title: '{{t("Edit")}}',
'x-component': 'Action.Link',
'x-component-props': {
type: 'primary',
},
properties: {
drawer: {
type: 'void',
'x-component': 'Action.Drawer',
'x-decorator': 'Form',
'x-decorator-props': {
useValues: '{{ cm.useValuesFromRecord }}',
},
title: '{{t("Edit provider")}}',
properties: {
...formProperties,
footer: {
type: 'void',
'x-component': 'Action.Drawer.Footer',
properties: {
cancel: {
title: '{{t("Cancel")}}',
'x-component': 'Action',
'x-component-props': {
useAction: '{{ cm.useCancelAction }}',
},
},
submit: {
title: '{{t("Submit")}}',
'x-component': 'Action',
'x-component-props': {
type: 'primary',
useAction: '{{ cm.useUpdateAction }}',
},
},
},
},
},
},
},
},
delete: {
type: 'void',
title: '{{ t("Delete") }}',
'x-component': 'Action.Link',
'x-component-props': {
confirm: {
title: "{{t('Delete role')}}",
content: "{{t('Are you sure you want to delete it?')}}",
},
useAction: '{{cm.useDestroyAction}}',
},
},
},
},
},
},
},
},
},
},
},
};

View File

@ -0,0 +1,3 @@
export { default } from './server';
export const namespace = require('../package.json').name;

View File

@ -0,0 +1,24 @@
import { Context } from '@nocobase/actions';
import { createOIDCClient } from '../shared/createOIDCClient';
import { OIDCProvider } from '../shared/types';
export const getAuthUrl = async (ctx: Context, next) => {
const {
params: { values },
} = ctx.action;
const providerRepo = ctx.db.getRepository('oidcProviders');
const record = await providerRepo.findOne({
filter: {
clientId: values.clientId,
},
});
const provider: OIDCProvider = record.toJSON();
const client = await createOIDCClient(provider);
ctx.body = client.authorizationUrl({
nonce: ctx.OIDC_NONCE,
scope: 'openid profile',
});
return next();
};

View File

@ -0,0 +1,27 @@
import { Context } from '@nocobase/actions';
export const redirect = async (ctx: Context, next) => {
const { params } = ctx.action;
const template = `
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title></title>
</head>
<body>
<script>
window.opener.postMessage(${JSON.stringify(params)}, '*');
</script>
</body>
</html>
`;
ctx.body = template;
ctx.withoutDataWrapping = true;
await next();
};

View File

@ -0,0 +1,52 @@
import { Context } from '@nocobase/actions';
import { createOIDCClient } from '../shared/createOIDCClient';
import { OIDCProvider } from '../shared/types';
export const oidc = async (ctx: Context, next) => {
const {
params: { values },
} = ctx.action;
const providerRepo = ctx.db.getRepository('oidcProviders');
const record = await providerRepo.findOne({
filter: {
clientId: values.clientId,
},
});
const provider: OIDCProvider = record.toJSON();
const client = await createOIDCClient(provider);
const tokens = await client.callback(
provider.redirectUrl,
{
code: values.code,
},
{
nonce: ctx.OIDC_NONCE,
},
);
const userinfo = await client.userinfo(tokens.access_token);
const usersRepo = ctx.db.getRepository('users');
const name = userinfo.preferred_username || userinfo.nickname || userinfo.name;
let user = await usersRepo.findOne({
filter: {
nickname: name,
email: userinfo.email ?? null,
},
});
if (!user) {
user = await usersRepo.create({
values: {
nickname: name,
},
});
}
ctx.state.currentUser = user;
return next();
};

View File

@ -0,0 +1,86 @@
import { CollectionOptions } from '@nocobase/database';
export default {
name: 'oidcProviders',
title: '{{t("OIDC Providers")}}',
fields: [
{
title: 'Provider 名称',
comment: 'Provider 名称',
type: 'string',
name: 'title',
},
{
comment: '客户端id',
type: 'string',
name: 'clientId',
unique: true,
},
{
comment: '客户端密钥',
type: 'string',
name: 'clientSecret',
},
{
title: 'issuer',
comment: '用于标识 token 发放来源的字段',
type: 'string',
name: 'issuer',
},
{
comment: '用于获取当前 Provider 支持的各端点信息和支持的模式、参数信息,可公开访问',
type: 'string',
name: 'openidConfiguration',
},
{
comment: '单点登录的地址 ',
type: 'string',
name: 'authorizeUrl',
},
{
comment: '获取取 token 的接口地址',
type: 'string',
name: 'tokenUrl',
},
{
comment: '令牌吊销 token 的接口',
type: 'string',
name: 'revokeUrl',
},
{
comment: '验证 id_token 的接口',
type: 'string',
name: 'jwksUrl',
},
{
comment: '获取用户信息的接口',
type: 'string',
name: 'userinfoUrl',
},
{
comment: '是否启用',
type: 'boolean',
name: 'enabled',
},
{
comment: '重定向回调地址',
type: 'string',
name: 'redirectUrl',
},
{
comment: '登出端点',
type: 'string',
name: 'logoutUrl',
},
{
comment: 'id token 加密方式',
type: 'string',
name: 'idTokenSignAlg',
},
{
comment: '获取用户信息的接口',
type: 'json',
name: 'attrsMap',
},
],
} as CollectionOptions;

View File

@ -0,0 +1 @@
export { default } from './plugin';

View File

@ -0,0 +1,62 @@
import UsersPlugin from '@nocobase/plugin-users';
import { InstallOptions, Plugin } from '@nocobase/server';
import { resolve } from 'path';
import { generators } from 'openid-client';
import { oidc } from './authenticators/oidc';
import { getAuthUrl } from './actions/getAuthUrl';
import { redirect } from './actions/redirect';
export class OidcPlugin extends Plugin {
#OIDC_NONCE = null;
afterAdd() {}
beforeLoad() {}
async load() {
// 导入 collection
await this.db.import({
directory: resolve(__dirname, 'collections'),
});
// 获取 User 插件
const userPlugin = this.app.getPlugin('users') as UsersPlugin;
// 注册 OIDC 验证器
userPlugin.authenticators.register('oidc', oidc);
// 注册接口
this.app.resource({
name: 'oidc',
actions: {
getAuthUrl,
redirect,
},
});
// 注册中间件,处理 nonce 值
this.app.use(async (ctx, next) => {
if (ctx.url.startsWith('/api/users:signin?authenticator=oidc')) {
ctx.OIDC_NONCE = this.#OIDC_NONCE;
}
if (ctx.url.startsWith('/api/oidc:getAuthUrl')) {
ctx.OIDC_NONCE = this.#OIDC_NONCE = generators.nonce();
}
await next();
});
// 开放访问权限
this.app.acl.allow('oidcProviders', '*');
this.app.acl.allow('oidc', '*');
}
async install(options?: InstallOptions) {}
async afterEnable() {}
async afterDisable() {}
async remove() {}
}
export default OidcPlugin;

View File

@ -0,0 +1,12 @@
import { Issuer } from 'openid-client';
import { IDTOKEN_SIGN_ALG, OIDCProvider } from './types';
export const createOIDCClient = async (provider: OIDCProvider) => {
const issuer = await Issuer.discover(provider.issuer);
return new issuer.Client({
client_id: provider.clientId,
client_secret: provider.clientSecret,
response_types: ['code'],
id_token_signed_response_alg: provider.idTokenSignAlg,
});
};

View File

@ -0,0 +1,22 @@
export enum IDTOKEN_SIGN_ALG {
HS256 = 'HS256',
RS256 = 'RS256',
}
export interface OIDCProvider {
title?: string;
clientId?: string;
clientSecret?: string;
issuer?: string;
openidConfiguration?: string;
authorizeUrl?: string;
tokenUrl?: string;
revodeUrl?: string;
jwksUrl?: string;
userinfoUrl?: string;
enable?: string;
redirectUrl?: string;
logoutUrl?: string;
idTokenSignAlg?: string;
attrsMap?: Record<string, string>;
}

View File

@ -23571,4 +23571,4 @@ yocto-queue@^0.1.0:
zwitch@^1.0.0:
version "1.0.5"
resolved "https://registry.npmjs.org/zwitch/-/zwitch-1.0.5.tgz#d11d7381ffed16b742f6af7b3f223d5cd9fe9920"
integrity sha512-V50KMwwzqJV0NpZIZFwfOD5/lyny3WlSzRiXgA0G7VUnRlqttta1L6UQIHzd6EuBY/cHGfwTIck7w1yH6Q5zUw==
integrity sha512-V50KMwwzqJV0NpZIZFwfOD5/lyny3WlSzRiXgA0G7VUnRlqttta1L6UQIHzd6EuBY/cHGfwTIck7w1yH6Q5zUw==