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:
parent
78fe77e2cf
commit
1ac0032e5c
1
packages/app/client/src/plugins/oidc.ts
Normal file
1
packages/app/client/src/plugins/oidc.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export { default } from '@nocobase/plugin-oidc/client';
|
@ -18,18 +18,19 @@ import {
|
|||||||
RemoteRouteSwitchProvider,
|
RemoteRouteSwitchProvider,
|
||||||
RouteSchemaComponent,
|
RouteSchemaComponent,
|
||||||
RouteSwitch,
|
RouteSwitch,
|
||||||
useRoutes
|
useRoutes,
|
||||||
} from '../route-switch';
|
} from '../route-switch';
|
||||||
import {
|
import {
|
||||||
AntdSchemaComponentProvider,
|
AntdSchemaComponentProvider,
|
||||||
DesignableSwitch,
|
DesignableSwitch,
|
||||||
MenuItemInitializers,
|
MenuItemInitializers,
|
||||||
SchemaComponentProvider
|
SchemaComponentProvider,
|
||||||
} from '../schema-component';
|
} from '../schema-component';
|
||||||
import { SchemaInitializerProvider } from '../schema-initializer';
|
import { SchemaInitializerProvider } from '../schema-initializer';
|
||||||
import { BlockTemplateDetails, BlockTemplatePage, SchemaTemplateShortcut } from '../schema-templates';
|
import { BlockTemplateDetails, BlockTemplatePage, SchemaTemplateShortcut } from '../schema-templates';
|
||||||
import { SystemSettingsProvider, SystemSettingsShortcut } from '../system-settings';
|
import { SystemSettingsProvider, SystemSettingsShortcut } from '../system-settings';
|
||||||
import { SigninPage, SignupPage } from '../user';
|
import { SigninPage, SignupPage } from '../user';
|
||||||
|
import { SigninPageExtensionProvider } from '../user/SigninPageExtension';
|
||||||
import { compose } from './compose';
|
import { compose } from './compose';
|
||||||
|
|
||||||
export interface ApplicationOptions {
|
export interface ApplicationOptions {
|
||||||
@ -112,6 +113,7 @@ export class Application {
|
|||||||
});
|
});
|
||||||
this.use(BlockSchemaComponentProvider);
|
this.use(BlockSchemaComponentProvider);
|
||||||
this.use(AntdSchemaComponentProvider);
|
this.use(AntdSchemaComponentProvider);
|
||||||
|
this.use(SigninPageExtensionProvider);
|
||||||
this.use(ACLProvider);
|
this.use(ACLProvider);
|
||||||
this.use(RemoteDocumentTitleProvider);
|
this.use(RemoteDocumentTitleProvider);
|
||||||
this.use(PMProvider);
|
this.use(PMProvider);
|
||||||
|
@ -1,9 +1,11 @@
|
|||||||
import { ISchema, useForm } from '@formily/react';
|
import { ISchema, useForm } from '@formily/react';
|
||||||
import { Tabs } from 'antd';
|
import { Space, Tabs } from 'antd';
|
||||||
import React, { useCallback } from 'react';
|
import React, { useCallback, useContext } from 'react';
|
||||||
|
import { css } from '@emotion/css';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Link, useHistory, useLocation } from 'react-router-dom';
|
import { Link, useHistory, useLocation } from 'react-router-dom';
|
||||||
import { SchemaComponent, useAPIClient, useCurrentDocumentTitle, useSystemSettings } from '..';
|
import { SchemaComponent, useAPIClient, useCurrentDocumentTitle, useSystemSettings } from '..';
|
||||||
|
import { useSigninPageExtension } from './SigninPageExtension';
|
||||||
import VerificationCode from './VerificationCode';
|
import VerificationCode from './VerificationCode';
|
||||||
|
|
||||||
const passwordForm: ISchema = {
|
const passwordForm: ISchema = {
|
||||||
@ -47,7 +49,7 @@ const passwordForm: ISchema = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
function useRedirect(next = '/admin') {
|
export function useRedirect(next = '/admin') {
|
||||||
const location = useLocation<any>();
|
const location = useLocation<any>();
|
||||||
const history = useHistory();
|
const history = useHistory();
|
||||||
const redirect = location?.['query']?.redirect;
|
const redirect = location?.['query']?.redirect;
|
||||||
@ -130,10 +132,16 @@ export const SigninPage = (props: SigninPageProps) => {
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
useCurrentDocumentTitle('Signin');
|
useCurrentDocumentTitle('Signin');
|
||||||
const ctx = useSystemSettings();
|
const ctx = useSystemSettings();
|
||||||
|
const signinExtension = useSigninPageExtension();
|
||||||
const { allowSignUp, smsAuthEnabled } = ctx?.data?.data || {};
|
const { allowSignUp, smsAuthEnabled } = ctx?.data?.data || {};
|
||||||
const { schema, components, scope } = props;
|
const { schema, components, scope } = props;
|
||||||
return (
|
return (
|
||||||
<div>
|
<Space
|
||||||
|
direction="vertical"
|
||||||
|
className={css`
|
||||||
|
display: flex;
|
||||||
|
`}
|
||||||
|
>
|
||||||
{smsAuthEnabled ? (
|
{smsAuthEnabled ? (
|
||||||
<Tabs defaultActiveKey="password">
|
<Tabs defaultActiveKey="password">
|
||||||
<Tabs.TabPane tab={t('Sign in via account')} key="password">
|
<Tabs.TabPane tab={t('Sign in via account')} key="password">
|
||||||
@ -157,11 +165,12 @@ export const SigninPage = (props: SigninPageProps) => {
|
|||||||
schema={schema || passwordForm}
|
schema={schema || passwordForm}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
<div>{signinExtension}</div>
|
||||||
{allowSignUp && (
|
{allowSignUp && (
|
||||||
<div>
|
<div>
|
||||||
<Link to="/signup">{t('Create an account')}</Link>
|
<Link to="/signup">{t('Create an account')}</Link>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</Space>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
24
packages/core/client/src/user/SigninPageExtension.tsx
Normal file
24
packages/core/client/src/user/SigninPageExtension.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
@ -2,4 +2,4 @@ export * from './CurrentUser';
|
|||||||
export * from './CurrentUserProvider';
|
export * from './CurrentUserProvider';
|
||||||
export * from './SigninPage';
|
export * from './SigninPage';
|
||||||
export * from './SignupPage';
|
export * from './SignupPage';
|
||||||
|
export * from './SigninPageExtension';
|
||||||
|
@ -66,6 +66,7 @@ export class PluginManager {
|
|||||||
if (options?.method !== 'install' || options.reload) {
|
if (options?.method !== 'install' || options.reload) {
|
||||||
await this.repository.load();
|
await this.repository.load();
|
||||||
}
|
}
|
||||||
|
this.app.acl.allow('applicationPlugins', 'list');
|
||||||
});
|
});
|
||||||
this.addStaticMultiple(options.plugins);
|
this.addStaticMultiple(options.plugins);
|
||||||
}
|
}
|
||||||
@ -329,7 +330,9 @@ export class PluginManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static getPluginPkgPrefix() {
|
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) {
|
static async findPackage(name: string) {
|
||||||
|
4
packages/plugins/oidc/client.d.ts
vendored
Executable file
4
packages/plugins/oidc/client.d.ts
vendored
Executable file
@ -0,0 +1,4 @@
|
|||||||
|
// @ts-nocheck
|
||||||
|
export * from './lib/client';
|
||||||
|
export { default } from './lib/client';
|
||||||
|
|
30
packages/plugins/oidc/client.js
Executable file
30
packages/plugins/oidc/client.js
Executable 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];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
12
packages/plugins/oidc/package.json
Normal file
12
packages/plugins/oidc/package.json
Normal 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
4
packages/plugins/oidc/server.d.ts
vendored
Executable file
@ -0,0 +1,4 @@
|
|||||||
|
// @ts-nocheck
|
||||||
|
export * from './lib/server';
|
||||||
|
export { default } from './lib/server';
|
||||||
|
|
30
packages/plugins/oidc/server.js
Executable file
30
packages/plugins/oidc/server.js
Executable 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];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
101
packages/plugins/oidc/src/client/OIDCList.tsx
Normal file
101
packages/plugins/oidc/src/client/OIDCList.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
23
packages/plugins/oidc/src/client/OIDCPanel.tsx
Normal file
23
packages/plugins/oidc/src/client/OIDCPanel.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
27
packages/plugins/oidc/src/client/RedirectURLInput.tsx
Normal file
27
packages/plugins/oidc/src/client/RedirectURLInput.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
});
|
38
packages/plugins/oidc/src/client/index.tsx
Normal file
38
packages/plugins/oidc/src/client/index.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
20
packages/plugins/oidc/src/client/locale/en-US.ts
Normal file
20
packages/plugins/oidc/src/client/locale/en-US.ts
Normal 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',
|
||||||
|
};
|
24
packages/plugins/oidc/src/client/locale/index.ts
Normal file
24
packages/plugins/oidc/src/client/locale/index.ts
Normal 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);
|
||||||
|
}
|
1
packages/plugins/oidc/src/client/locale/ja-JP.ts
Normal file
1
packages/plugins/oidc/src/client/locale/ja-JP.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export default {};
|
1
packages/plugins/oidc/src/client/locale/ru-RU.ts
Normal file
1
packages/plugins/oidc/src/client/locale/ru-RU.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export default {};
|
1
packages/plugins/oidc/src/client/locale/tr-TR.ts
Normal file
1
packages/plugins/oidc/src/client/locale/tr-TR.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export default {};
|
23
packages/plugins/oidc/src/client/locale/zh-CN.ts
Normal file
23
packages/plugins/oidc/src/client/locale/zh-CN.ts
Normal 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': '删除身份提供者',
|
||||||
|
};
|
451
packages/plugins/oidc/src/client/schemas/oidc.ts
Normal file
451
packages/plugins/oidc/src/client/schemas/oidc.ts
Normal 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}}',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
3
packages/plugins/oidc/src/index.ts
Normal file
3
packages/plugins/oidc/src/index.ts
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
export { default } from './server';
|
||||||
|
|
||||||
|
export const namespace = require('../package.json').name;
|
24
packages/plugins/oidc/src/server/actions/getAuthUrl.ts
Normal file
24
packages/plugins/oidc/src/server/actions/getAuthUrl.ts
Normal 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();
|
||||||
|
};
|
27
packages/plugins/oidc/src/server/actions/redirect.ts
Normal file
27
packages/plugins/oidc/src/server/actions/redirect.ts
Normal 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();
|
||||||
|
};
|
52
packages/plugins/oidc/src/server/authenticators/oidc.ts
Normal file
52
packages/plugins/oidc/src/server/authenticators/oidc.ts
Normal 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();
|
||||||
|
};
|
@ -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;
|
1
packages/plugins/oidc/src/server/index.ts
Normal file
1
packages/plugins/oidc/src/server/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export { default } from './plugin';
|
62
packages/plugins/oidc/src/server/plugin.ts
Normal file
62
packages/plugins/oidc/src/server/plugin.ts
Normal 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;
|
12
packages/plugins/oidc/src/server/shared/createOIDCClient.ts
Normal file
12
packages/plugins/oidc/src/server/shared/createOIDCClient.ts
Normal 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,
|
||||||
|
});
|
||||||
|
};
|
22
packages/plugins/oidc/src/server/shared/types.ts
Normal file
22
packages/plugins/oidc/src/server/shared/types.ts
Normal 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>;
|
||||||
|
}
|
@ -23571,4 +23571,4 @@ yocto-queue@^0.1.0:
|
|||||||
zwitch@^1.0.0:
|
zwitch@^1.0.0:
|
||||||
version "1.0.5"
|
version "1.0.5"
|
||||||
resolved "https://registry.npmjs.org/zwitch/-/zwitch-1.0.5.tgz#d11d7381ffed16b742f6af7b3f223d5cd9fe9920"
|
resolved "https://registry.npmjs.org/zwitch/-/zwitch-1.0.5.tgz#d11d7381ffed16b742f6af7b3f223d5cd9fe9920"
|
||||||
integrity sha512-V50KMwwzqJV0NpZIZFwfOD5/lyny3WlSzRiXgA0G7VUnRlqttta1L6UQIHzd6EuBY/cHGfwTIck7w1yH6Q5zUw==
|
integrity sha512-V50KMwwzqJV0NpZIZFwfOD5/lyny3WlSzRiXgA0G7VUnRlqttta1L6UQIHzd6EuBY/cHGfwTIck7w1yH6Q5zUw==
|
Loading…
Reference in New Issue
Block a user