diff --git a/packages/plugins/@nocobase/plugin-oidc/src/client/Options.tsx b/packages/plugins/@nocobase/plugin-oidc/src/client/Options.tsx
index 391615f19..cbc736807 100644
--- a/packages/plugins/@nocobase/plugin-oidc/src/client/Options.tsx
+++ b/packages/plugins/@nocobase/plugin-oidc/src/client/Options.tsx
@@ -1,5 +1,5 @@
import { CopyOutlined } from '@ant-design/icons';
-import { ArrayItems } from '@formily/antd-v5';
+import { ArrayItems, FormTab } from '@formily/antd-v5';
import { observer } from '@formily/react';
import { FormItem, Input, SchemaComponent } from '@nocobase/client';
import { Card, Space, message } from 'antd';
@@ -9,143 +9,6 @@ import { lang, useOidcTranslation } from './locale';
const schema = {
type: 'object',
properties: {
- oidc: {
- type: 'object',
- properties: {
- issuer: {
- type: 'string',
- title: '{{t("Issuer")}}',
- 'x-component': 'Input',
- 'x-decorator': 'FormItem',
- required: true,
- },
- clientId: {
- type: 'string',
- title: '{{t("Client ID")}}',
- 'x-component': 'Input',
- 'x-decorator': 'FormItem',
- required: true,
- },
- clientSecret: {
- type: 'string',
- title: '{{t("Client Secret")}}',
- 'x-component': 'Input',
- 'x-decorator': 'FormItem',
- required: true,
- },
- scope: {
- type: 'string',
- title: '{{t("scope")}}',
- 'x-component': 'Input',
- 'x-decorator': 'FormItem',
- 'x-decorator-props': {
- tooltip: '{{t("Default: openid profile email")}}',
- },
- },
- idTokenSignedResponseAlg: {
- type: 'string',
- title: '{{t("id_token signed response algorithm")}}',
- 'x-component': 'Select',
- 'x-decorator': 'FormItem',
- enum: [
- { label: 'HS256', value: 'HS256' },
- { label: 'HS384', value: 'HS384' },
- { label: 'HS512', value: 'HS512' },
- { label: 'RS256', value: 'RS256' },
- { label: 'RS384', value: 'RS384' },
- { label: 'RS512', value: 'RS512' },
- { label: 'ES256', value: 'ES256' },
- { label: 'ES384', value: 'ES384' },
- { label: 'ES512', value: 'ES512' },
- { label: 'PS256', value: 'PS256' },
- { label: 'PS384', value: 'PS384' },
- { label: 'PS512', value: 'PS512' },
- ],
- },
- http: {
- type: 'boolean',
- title: '{{t("HTTP")}}',
- 'x-component': 'Checkbox',
- 'x-decorator': 'FormItem',
- },
- port: {
- type: 'number',
- title: '{{t("Port")}}',
- 'x-component': 'InputNumber',
- 'x-decorator': 'FormItem',
- 'x-component-props': {
- style: {
- width: '15%',
- minWidth: '100px',
- },
- },
- },
- fieldMap: {
- title: '{{t("Field Map")}}',
- type: 'array',
- 'x-decorator': 'FormItem',
- 'x-component': 'ArrayItems',
- items: {
- type: 'object',
- 'x-decorator': 'ArrayItems.Item',
- properties: {
- space: {
- type: 'void',
- 'x-component': 'Space',
- properties: {
- source: {
- type: 'string',
- 'x-decorator': 'FormItem',
- 'x-component': 'Input',
- 'x-component-props': {
- placeholder: '{{t("source")}}',
- },
- },
- target: {
- type: 'string',
- 'x-decorator': 'FormItem',
- 'x-component': 'Select',
- 'x-component-props': {
- placeholder: '{{t("target")}}',
- },
- enum: [
- { label: lang('Nickname'), value: 'nickname' },
- { label: lang('Email'), value: 'email' },
- { label: lang('Phone'), value: 'phone' },
- { label: lang('Username'), value: 'username' },
- ],
- },
- remove: {
- type: 'void',
- 'x-decorator': 'FormItem',
- 'x-component': 'ArrayItems.Remove',
- },
- },
- },
- },
- },
- properties: {
- add: {
- type: 'void',
- title: 'Add',
- 'x-component': 'ArrayItems.Addition',
- },
- },
- },
- userBindField: {
- type: 'string',
- title: '{{t("Use this field to bind the user")}}',
- 'x-component': 'Select',
- 'x-decorator': 'FormItem',
- default: 'email',
- enum: [
- { label: lang('Email'), value: 'email' },
- { label: lang('Username'), value: 'username' },
- ],
- required: true,
- },
- },
- },
public: {
type: 'object',
properties: {
@@ -158,6 +21,302 @@ const schema = {
},
},
},
+ oidc: {
+ type: 'object',
+ properties: {
+ collapse: {
+ type: 'void',
+ 'x-component': 'FormTab',
+ properties: {
+ basic: {
+ type: 'void',
+ 'x-component': 'FormTab.TabPane',
+ 'x-component-props': {
+ tab: lang('Basic configuration'),
+ },
+ properties: {
+ issuer: {
+ type: 'string',
+ title: '{{t("Issuer")}}',
+ 'x-component': 'Input',
+ 'x-decorator': 'FormItem',
+ required: true,
+ },
+ clientId: {
+ type: 'string',
+ title: '{{t("Client ID")}}',
+ 'x-component': 'Input',
+ 'x-decorator': 'FormItem',
+ required: true,
+ },
+ clientSecret: {
+ type: 'string',
+ title: '{{t("Client Secret")}}',
+ 'x-component': 'Input',
+ 'x-decorator': 'FormItem',
+ required: true,
+ },
+ scope: {
+ type: 'string',
+ title: '{{t("scope")}}',
+ 'x-component': 'Input',
+ 'x-decorator': 'FormItem',
+ 'x-decorator-props': {
+ tooltip: '{{t("Default: openid profile email")}}',
+ },
+ },
+ idTokenSignedResponseAlg: {
+ type: 'string',
+ title: '{{t("id_token signed response algorithm")}}',
+ 'x-component': 'Select',
+ 'x-decorator': 'FormItem',
+ enum: [
+ { label: 'HS256', value: 'HS256' },
+ { label: 'HS384', value: 'HS384' },
+ { label: 'HS512', value: 'HS512' },
+ { label: 'RS256', value: 'RS256' },
+ { label: 'RS384', value: 'RS384' },
+ { label: 'RS512', value: 'RS512' },
+ { label: 'ES256', value: 'ES256' },
+ { label: 'ES384', value: 'ES384' },
+ { label: 'ES512', value: 'ES512' },
+ { label: 'PS256', value: 'PS256' },
+ { label: 'PS384', value: 'PS384' },
+ { label: 'PS512', value: 'PS512' },
+ ],
+ },
+ },
+ },
+ mapping: {
+ type: 'void',
+ 'x-component': 'FormTab.TabPane',
+ 'x-component-props': {
+ tab: lang('Field mapping'),
+ },
+ properties: {
+ fieldMap: {
+ title: '{{t("Field Map")}}',
+ type: 'array',
+ 'x-decorator': 'FormItem',
+ 'x-component': 'ArrayItems',
+ items: {
+ type: 'object',
+ 'x-decorator': 'ArrayItems.Item',
+ properties: {
+ space: {
+ type: 'void',
+ 'x-component': 'Space',
+ properties: {
+ source: {
+ type: 'string',
+ 'x-decorator': 'FormItem',
+ 'x-component': 'Input',
+ 'x-component-props': {
+ placeholder: '{{t("source")}}',
+ },
+ },
+ target: {
+ type: 'string',
+ 'x-decorator': 'FormItem',
+ 'x-component': 'Select',
+ 'x-component-props': {
+ placeholder: '{{t("target")}}',
+ },
+ enum: [
+ { label: lang('Nickname'), value: 'nickname' },
+ { label: lang('Email'), value: 'email' },
+ { label: lang('Phone'), value: 'phone' },
+ { label: lang('Username'), value: 'username' },
+ ],
+ },
+ remove: {
+ type: 'void',
+ 'x-decorator': 'FormItem',
+ 'x-component': 'ArrayItems.Remove',
+ },
+ },
+ },
+ },
+ },
+ properties: {
+ add: {
+ type: 'void',
+ title: 'Add',
+ 'x-component': 'ArrayItems.Addition',
+ },
+ },
+ },
+ userBindField: {
+ type: 'string',
+ title: '{{t("Use this field to bind the user")}}',
+ 'x-component': 'Select',
+ 'x-decorator': 'FormItem',
+ default: 'email',
+ enum: [
+ { label: lang('Email'), value: 'email' },
+ { label: lang('Username'), value: 'username' },
+ ],
+ required: true,
+ },
+ },
+ },
+ advanced: {
+ type: 'void',
+ 'x-component': 'FormTab.TabPane',
+ 'x-component-props': {
+ tab: lang('Advanced configuration'),
+ },
+ properties: {
+ http: {
+ type: 'boolean',
+ title: '{{t("HTTP")}}',
+ 'x-component': 'Checkbox',
+ 'x-decorator': 'FormItem',
+ 'x-decorator-props': {
+ tooltip: '{{t("Check if NocoBase is running on HTTP protocol")}}',
+ },
+ },
+ port: {
+ type: 'number',
+ title: '{{t("Port")}}',
+ 'x-component': 'InputNumber',
+ 'x-decorator': 'FormItem',
+ 'x-decorator-props': {
+ tooltip: '{{t("The port number of the NocoBase service if it is not 80 or 443")}}',
+ },
+ 'x-component-props': {
+ style: {
+ width: '15%',
+ minWidth: '100px',
+ },
+ },
+ },
+ stateToken: {
+ type: 'string',
+ title: '{{t("State token")}}',
+ 'x-component': 'Input',
+ 'x-decorator': 'FormItem',
+ description: lang(
+ "The state token helps prevent CSRF attacks. It's recommended to leave it blank for automatic random generation.",
+ ),
+ },
+ exchangeBodyKeys: {
+ type: 'array',
+ title: '{{t("Pass parameters in the authorization code grant exchange")}}',
+ 'x-decorator': 'FormItem',
+ 'x-component': 'ArrayItems',
+ default: [
+ { paramName: '', optionsKey: 'clientId' },
+ {
+ paramName: '',
+ optionsKey: 'clientSecret',
+ },
+ ],
+ items: {
+ type: 'object',
+ 'x-decorator': 'ArrayItems.Item',
+ properties: {
+ space: {
+ type: 'void',
+ 'x-component': 'Space',
+ properties: {
+ enabled: {
+ type: 'boolean',
+ 'x-decorator': 'FormItem',
+ 'x-component': 'Checkbox',
+ },
+ optionsKey: {
+ type: 'string',
+ 'x-decorator': 'FormItem',
+ 'x-decorator-props': {
+ style: {
+ width: '100px',
+ },
+ },
+ 'x-component': 'Select',
+ 'x-read-pretty': true,
+ enum: [
+ { label: lang('Client ID'), value: 'clientId' },
+ { label: lang('Client Secret'), value: 'clientSecret' },
+ ],
+ },
+ paramName: {
+ type: 'string',
+ 'x-decorator': 'FormItem',
+ 'x-component': 'Input',
+ 'x-component-props': {
+ placeholder: '{{t("Parameter name")}}',
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+ userInfoMethod: {
+ type: 'string',
+ title: '{{t("Method to call the user info endpoint")}}',
+ 'x- decorator': 'FormItem',
+ 'x-component': 'Radio.Group',
+ default: 'GET',
+ enum: [
+ {
+ label: 'GET',
+ value: 'GET',
+ },
+ {
+ label: 'POST',
+ value: 'POST',
+ },
+ ],
+ 'x-reactions': [
+ {
+ dependencies: ['.accessTokenVia'],
+ when: '{{$deps[0] === "query"}}',
+ fulfill: {
+ state: {
+ value: 'GET',
+ },
+ },
+ },
+ {
+ dependencies: ['.accessTokenVia'],
+ when: '{{$deps[0] === "body"}}',
+ fulfill: {
+ state: {
+ value: 'POST',
+ },
+ },
+ },
+ ],
+ },
+ accessTokenVia: {
+ type: 'string',
+ title: '{{t("Where to put the access token when calling the user info endpoint")}}',
+ 'x- decorator': 'FormItem',
+ 'x-component': 'Radio.Group',
+ default: 'header',
+ enum: [
+ {
+ label: lang('Header'),
+ value: 'header',
+ },
+ {
+ label: lang('Body (Use with POST method)'),
+ value: 'body',
+ },
+ {
+ label: lang('Query parameters (Use with GET method)'),
+ value: 'query',
+ },
+ ],
+ },
+ },
+ },
+ },
+ },
+ },
+ },
usage: {
type: 'void',
'x-component': 'Usage',
@@ -187,5 +346,5 @@ const Usage = observer(() => {
export const Options = () => {
const { t } = useOidcTranslation();
- return ;
+ return ;
};
diff --git a/packages/plugins/@nocobase/plugin-oidc/src/locale/en-US.json b/packages/plugins/@nocobase/plugin-oidc/src/locale/en-US.json
index 706425673..4ce29c41f 100644
--- a/packages/plugins/@nocobase/plugin-oidc/src/locale/en-US.json
+++ b/packages/plugins/@nocobase/plugin-oidc/src/locale/en-US.json
@@ -21,5 +21,20 @@
"Use this field to bind the user": "Use this field to bind the user",
"Sign up automatically when the user does not exist": "Sign up automatically when the user does not exist",
"Username must be 2-16 characters in length (excluding @.<>\"'/)": "Username must be 2-16 characters in length (excluding @.<>\"'/)",
- "User not found": "User not found"
+ "User not found": "User not found",
+ "Basic configuration": "Basic configuration",
+ "Field mapping": "Field mapping",
+ "Advanced configuration": "Advanced configuration",
+ "Usage": "Usage",
+ "Redirect URL": "Redirect URL",
+ "Check if NocoBase is running on HTTP protocol": "Check if NocoBase is running on HTTP protocol",
+ "The port number of the NocoBase service if it is not 80 or 443": "The port number of the NocoBase service if it is not 80 or 443",
+ "Pass parameters in the authorization code grant exchange": "Pass parameters in the authorization code grant exchange",
+ "Method to call the user info endpoint": "Method to call the user info endpoint",
+ "Where to put the access token when calling the user info endpoint": "Where to put the access token when calling the user info endpoint",
+ "Header": "Header",
+ "Body (Use with POST method)": "Body (Use with POST method)",
+ "Query parameters (Use with GET method)": "Query parameters (Use with GET method)",
+ "Parameter name": "Parameter name",
+ "The state token helps prevent CSRF attacks. It's recommended to leave it blank for automatic random generation.": "The state token helps prevent CSRF attacks. It's recommended to leave it blank for automatic random generation."
}
diff --git a/packages/plugins/@nocobase/plugin-oidc/src/locale/zh-CN.json b/packages/plugins/@nocobase/plugin-oidc/src/locale/zh-CN.json
index b6bffa34b..ba528657d 100644
--- a/packages/plugins/@nocobase/plugin-oidc/src/locale/zh-CN.json
+++ b/packages/plugins/@nocobase/plugin-oidc/src/locale/zh-CN.json
@@ -9,5 +9,20 @@
"Use this field to bind the user": "使用此字段绑定用户",
"Sign up automatically when the user does not exist": "用户不存在时自动注册",
"Username must be 2-16 characters in length (excluding @.<>\"'/)": "用户名必须为2-16个字符并且不包含@.<>\"'/)",
- "User not found": "用户不存在"
+ "User not found": "用户不存在",
+ "Basic configuration": "基础配置",
+ "Field mapping": "字段映射",
+ "Advanced configuration": "高级配置",
+ "Usage": "使用",
+ "Redirect URL": "回调 URL",
+ "Check if NocoBase is running on HTTP protocol": "NocoBase 应用为HTTP协议时勾选",
+ "The port number of the NocoBase service if it is not 80 or 443": "NocoBase 应用端口,默认 443/80",
+ "Pass parameters in the authorization code grant exchange": "使用 code 交换 token 时需要传递的参数",
+ "Method to call the user info endpoint": "访问获取用户信息的 API 的 HTTP 方法",
+ "Where to put the access token when calling the user info endpoint": "访问获取用户信息的 API 时 access_token 的传递方式",
+ "Header": "请求头 (Header, 默认)",
+ "Body (Use with POST method)": "请求体(Body, 配合 POST 方法使用)",
+ "Query parameters (Use with GET method)": "请求 URL 参数(Query, 配合 GET 方法使用)",
+ "Parameter name": "参数名",
+ "The state token helps prevent CSRF attacks. It's recommended to leave it blank for automatic random generation.": "state token 用于防止 CSRF 攻击,建议留空使用自动生成的随机值。"
}
diff --git a/packages/plugins/@nocobase/plugin-oidc/src/server/__tests__/oidc.test.ts b/packages/plugins/@nocobase/plugin-oidc/src/server/__tests__/oidc.test.ts
index c026d9361..3fec2e75b 100644
--- a/packages/plugins/@nocobase/plugin-oidc/src/server/__tests__/oidc.test.ts
+++ b/packages/plugins/@nocobase/plugin-oidc/src/server/__tests__/oidc.test.ts
@@ -52,7 +52,7 @@ describe('oidc', () => {
} as any);
const res = await agent.set('X-Authenticator', 'oidc-auth').resource('oidc').getAuthUrl();
expect(res.body.data).toBeDefined();
- const search = new URLSearchParams(res.body.data);
+ const search = new URLSearchParams(decodeURIComponent(res.body.data));
expect(search.get('token')).toBeDefined();
expect(search.get('name')).toBe('oidc-auth');
expect(res.headers['set-cookie']).toBeDefined();
diff --git a/packages/plugins/@nocobase/plugin-oidc/src/server/actions/getAuthUrl.ts b/packages/plugins/@nocobase/plugin-oidc/src/server/actions/getAuthUrl.ts
index 98a749910..67901c204 100644
--- a/packages/plugins/@nocobase/plugin-oidc/src/server/actions/getAuthUrl.ts
+++ b/packages/plugins/@nocobase/plugin-oidc/src/server/actions/getAuthUrl.ts
@@ -7,8 +7,8 @@ export const getAuthUrl = async (ctx: Context, next: Next) => {
const app = ctx.app.name;
const auth = ctx.auth as OIDCAuth;
const client = await auth.createOIDCClient();
- const { scope } = auth.getOptions();
- const token = nanoid(15);
+ const { scope, stateToken } = auth.getOptions();
+ const token = stateToken || nanoid(15);
ctx.cookies.set(cookieName, token, {
httpOnly: true,
domain: ctx.hostname,
@@ -17,7 +17,7 @@ export const getAuthUrl = async (ctx: Context, next: Next) => {
response_type: 'code',
scope: scope || 'openid email profile',
redirect_uri: auth.getRedirectUri(),
- state: `token=${token}&name=${ctx.headers['x-authenticator']}&app=${app}`,
+ state: encodeURIComponent(`token=${token}&name=${ctx.headers['x-authenticator']}&app=${app}`),
});
return next();
diff --git a/packages/plugins/@nocobase/plugin-oidc/src/server/actions/redirect.ts b/packages/plugins/@nocobase/plugin-oidc/src/server/actions/redirect.ts
index 18cd216ef..fb642bd65 100644
--- a/packages/plugins/@nocobase/plugin-oidc/src/server/actions/redirect.ts
+++ b/packages/plugins/@nocobase/plugin-oidc/src/server/actions/redirect.ts
@@ -6,7 +6,7 @@ export const redirect = async (ctx: Context, next: Next) => {
const {
params: { state },
} = ctx.action;
- const search = new URLSearchParams(state);
+ const search = new URLSearchParams(decodeURIComponent(state));
const authenticator = search.get('name');
const appName = search.get('app');
let prefix = '';
@@ -21,6 +21,7 @@ export const redirect = async (ctx: Context, next: Next) => {
const { token } = await auth.signIn();
ctx.redirect(`${prefix}/admin?authenticator=${authenticator}&token=${token}`);
} catch (error) {
+ ctx.logger.error('OIDC auth error', { error });
ctx.redirect(`${prefix}/signin?authenticator=${authenticator}&error=${error.message}`);
}
await next();
diff --git a/packages/plugins/@nocobase/plugin-oidc/src/server/oidc-auth.ts b/packages/plugins/@nocobase/plugin-oidc/src/server/oidc-auth.ts
index d8743bf61..5227717bf 100644
--- a/packages/plugins/@nocobase/plugin-oidc/src/server/oidc-auth.ts
+++ b/packages/plugins/@nocobase/plugin-oidc/src/server/oidc-auth.ts
@@ -24,6 +24,22 @@ export class OIDCAuth extends BaseAuth {
return this.options?.oidc || {};
}
+ getExchangeBody() {
+ const options = this.getOptions();
+ const { exchangeBodyKeys } = options;
+ if (!exchangeBodyKeys) {
+ return {};
+ }
+ const body = {};
+ exchangeBodyKeys
+ .filter((item: { enabled: boolean }) => item.enabled)
+ .forEach((item: { paramName: string; optionsKey: string }) => {
+ const name = item.paramName || item.optionsKey;
+ body[name] = options[item.optionsKey];
+ });
+ return body;
+ }
+
mapField(userInfo: { [source: string]: any }) {
const { fieldMap } = this.getOptions();
if (!fieldMap) {
@@ -51,18 +67,33 @@ export class OIDCAuth extends BaseAuth {
async validate() {
const ctx = this.ctx;
const { params: values } = ctx.action;
- const token = ctx.cookies.get(cookieName);
- const search = new URLSearchParams(values.state);
+ const { userInfoMethod = 'GET', accessTokenVia = 'header', stateToken } = this.getOptions();
+ const token = stateToken || ctx.cookies.get(cookieName);
+ const search = new URLSearchParams(decodeURIComponent(values.state));
if (search.get('token') !== token) {
ctx.logger.error('nocobase_oidc state mismatch', { method: 'validate' });
return null;
}
const client = await this.createOIDCClient();
- const tokens = await client.callback(this.getRedirectUri(), {
- code: values.code,
- iss: values.iss,
+ const tokens = await client.callback(
+ this.getRedirectUri(),
+ {
+ code: values.code,
+ iss: values.iss,
+ },
+ {},
+ { exchangeBody: this.getExchangeBody() },
+ );
+ const userInfo: { [key: string]: any } = await client.userinfo(tokens, {
+ method: userInfoMethod,
+ via: accessTokenVia !== 'query' ? accessTokenVia : 'header',
+ params:
+ accessTokenVia === 'query'
+ ? {
+ access_token: tokens.access_token,
+ }
+ : {},
});
- const userInfo: { [key: string]: any } = await client.userinfo(tokens.access_token);
const mappedUserInfo = this.mapField(userInfo);
const { nickname, username, name, sub, email, phone } = mappedUserInfo;
const authenticator = this.authenticator as AuthModel;