feat(oidc): add advanced options (#3364)

* fix(oidc): params issue

* fix: test

* fix: exchangeBody

* feat: add advanced settings

* fix: locale

* fix: bug

* feat: allow to set state token manually
This commit is contained in:
YANG QIA 2024-01-11 11:27:40 +08:00 committed by GitHub
parent 4d44c6e24e
commit 7d29c717eb
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 373 additions and 152 deletions

View File

@ -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,8 +9,31 @@ import { lang, useOidcTranslation } from './locale';
const schema = {
type: 'object',
properties: {
public: {
type: 'object',
properties: {
autoSignup: {
'x-decorator': 'FormItem',
type: 'boolean',
title: '{{t("Sign up automatically when the user does not exist")}}',
'x-component': 'Checkbox',
default: true,
},
},
},
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',
@ -62,24 +85,15 @@ const schema = {
{ 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',
},
mapping: {
type: 'void',
'x-component': 'FormTab.TabPane',
'x-component-props': {
style: {
width: '15%',
minWidth: '100px',
},
},
tab: lang('Field mapping'),
},
properties: {
fieldMap: {
title: '{{t("Field Map")}}',
type: 'array',
@ -146,15 +160,160 @@ const schema = {
},
},
},
public: {
type: 'object',
advanced: {
type: 'void',
'x-component': 'FormTab.TabPane',
'x-component-props': {
tab: lang('Advanced configuration'),
},
properties: {
autoSignup: {
'x-decorator': 'FormItem',
http: {
type: 'boolean',
title: '{{t("Sign up automatically when the user does not exist")}}',
title: '{{t("HTTP")}}',
'x-component': 'Checkbox',
default: true,
'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',
},
],
},
},
},
},
},
},
},
@ -187,5 +346,5 @@ const Usage = observer(() => {
export const Options = () => {
const { t } = useOidcTranslation();
return <SchemaComponent scope={{ t }} components={{ Usage, ArrayItems, Space }} schema={schema} />;
return <SchemaComponent scope={{ t }} components={{ Usage, ArrayItems, Space, FormTab }} schema={schema} />;
};

View File

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

View File

@ -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 攻击,建议留空使用自动生成的随机值。"
}

View File

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

View File

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

View File

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

View File

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