@typekcz-nocobase-plugins/plugin-oidc-plus 1.0.2 → 1.0.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/client/index.js +1 -3
- package/dist/externalVersion.js +9 -9
- package/package.json +10 -10
- package/src/client/OIDCButton.tsx +70 -0
- package/src/client/Options.tsx +359 -0
- package/src/client/index.tsx +19 -0
- package/src/client/locale/index.ts +18 -0
- package/src/constants.ts +7 -0
- package/src/index.ts +2 -0
- package/src/locale/en-US.json +40 -0
- package/src/locale/es-ES.json +25 -0
- package/src/locale/fr-FR.json +21 -0
- package/src/locale/ko_KR.json +28 -0
- package/src/locale/pt-BR.json +21 -0
- package/src/locale/zh-CN.json +28 -0
- package/src/server/__tests__/oidc.test.ts +283 -0
- package/src/server/actions/getAuthUrl.ts +25 -0
- package/src/server/actions/redirect.ts +32 -0
- package/src/server/index.ts +1 -0
- package/src/server/oidc-auth.ts +169 -0
- package/src/server/plugin.ts +63 -0
- package/src/swagger/index.ts +157 -0
- package/dist/node_modules/nanoid/LICENSE +0 -20
- package/dist/node_modules/nanoid/async/index.browser.cjs +0 -34
- package/dist/node_modules/nanoid/async/index.browser.js +0 -34
- package/dist/node_modules/nanoid/async/index.cjs +0 -35
- package/dist/node_modules/nanoid/async/index.d.ts +0 -56
- package/dist/node_modules/nanoid/async/index.js +0 -35
- package/dist/node_modules/nanoid/async/index.native.js +0 -26
- package/dist/node_modules/nanoid/async/package.json +0 -12
- package/dist/node_modules/nanoid/bin/nanoid.cjs +0 -55
- package/dist/node_modules/nanoid/index.browser.cjs +0 -34
- package/dist/node_modules/nanoid/index.browser.js +0 -34
- package/dist/node_modules/nanoid/index.cjs +0 -1
- package/dist/node_modules/nanoid/index.d.ts +0 -91
- package/dist/node_modules/nanoid/index.js +0 -45
- package/dist/node_modules/nanoid/nanoid.js +0 -1
- package/dist/node_modules/nanoid/non-secure/index.cjs +0 -21
- package/dist/node_modules/nanoid/non-secure/index.d.ts +0 -33
- package/dist/node_modules/nanoid/non-secure/index.js +0 -21
- package/dist/node_modules/nanoid/non-secure/package.json +0 -6
- package/dist/node_modules/nanoid/package.json +0 -1
- package/dist/node_modules/nanoid/url-alphabet/index.cjs +0 -3
- package/dist/node_modules/nanoid/url-alphabet/index.js +0 -3
- package/dist/node_modules/nanoid/url-alphabet/package.json +0 -6
- package/dist/node_modules/openid-client/lib/client.js +0 -1849
- package/dist/node_modules/openid-client/lib/device_flow_handle.js +0 -125
- package/dist/node_modules/openid-client/lib/errors.js +0 -55
- package/dist/node_modules/openid-client/lib/helpers/assert.js +0 -24
- package/dist/node_modules/openid-client/lib/helpers/base64url.js +0 -13
- package/dist/node_modules/openid-client/lib/helpers/client.js +0 -211
- package/dist/node_modules/openid-client/lib/helpers/consts.js +0 -7
- package/dist/node_modules/openid-client/lib/helpers/decode_jwt.js +0 -27
- package/dist/node_modules/openid-client/lib/helpers/deep_clone.js +0 -1
- package/dist/node_modules/openid-client/lib/helpers/defaults.js +0 -27
- package/dist/node_modules/openid-client/lib/helpers/generators.js +0 -14
- package/dist/node_modules/openid-client/lib/helpers/is_key_object.js +0 -4
- package/dist/node_modules/openid-client/lib/helpers/is_plain_object.js +0 -1
- package/dist/node_modules/openid-client/lib/helpers/issuer.js +0 -111
- package/dist/node_modules/openid-client/lib/helpers/keystore.js +0 -298
- package/dist/node_modules/openid-client/lib/helpers/merge.js +0 -24
- package/dist/node_modules/openid-client/lib/helpers/pick.js +0 -9
- package/dist/node_modules/openid-client/lib/helpers/process_response.js +0 -71
- package/dist/node_modules/openid-client/lib/helpers/request.js +0 -200
- package/dist/node_modules/openid-client/lib/helpers/unix_timestamp.js +0 -1
- package/dist/node_modules/openid-client/lib/helpers/weak_cache.js +0 -1
- package/dist/node_modules/openid-client/lib/helpers/webfinger_normalize.js +0 -71
- package/dist/node_modules/openid-client/lib/helpers/www_authenticate_parser.js +0 -14
- package/dist/node_modules/openid-client/lib/index.js +0 -1
- package/dist/node_modules/openid-client/lib/issuer.js +0 -191
- package/dist/node_modules/openid-client/lib/issuer_registry.js +0 -3
- package/dist/node_modules/openid-client/lib/passport_strategy.js +0 -205
- package/dist/node_modules/openid-client/lib/token_set.js +0 -35
- package/dist/node_modules/openid-client/package.json +0 -1
- package/dist/node_modules/openid-client/types/index.d.ts +0 -622
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
{
|
|
2
|
+
"Enable": "Enable",
|
|
3
|
+
"Issuer": "Issuer",
|
|
4
|
+
"OIDC manager": "OIDC manager",
|
|
5
|
+
"OIDC Providers": "OIDC Providers",
|
|
6
|
+
"Provider name": "Name",
|
|
7
|
+
"Client id": "Client id",
|
|
8
|
+
"Client secret": "Client secret",
|
|
9
|
+
"Openid configuration": "Openid configuration",
|
|
10
|
+
"Authorization endpoint": "Authorization endpoint",
|
|
11
|
+
"Access token endpoint": "Access token endpoint",
|
|
12
|
+
"JWKS endpoint": "JWKS endpoint",
|
|
13
|
+
"Userinfo endpoint": "Userinfo endpoint",
|
|
14
|
+
"Redirect url": "Redirect url",
|
|
15
|
+
"Logout endpoint": "Logout endpoint",
|
|
16
|
+
"Id token sign alg": "Id token sign alg",
|
|
17
|
+
"Add provider": "Add",
|
|
18
|
+
"Edit provider": "Edit",
|
|
19
|
+
"Delete provider": "Delete",
|
|
20
|
+
"Sign in button name, which will be displayed on the sign in page": "Sign in button name, which will be displayed on the sign in page",
|
|
21
|
+
"Use this field to bind the user": "Use this field to bind the user",
|
|
22
|
+
"Sign up automatically when the user does not exist": "Sign up automatically when the user does not exist",
|
|
23
|
+
"Username must be 2-16 characters in length (excluding @.<>\"'/)": "Username must be 2-16 characters in length (excluding @.<>\"'/)",
|
|
24
|
+
"User not found": "User not found",
|
|
25
|
+
"Basic configuration": "Basic configuration",
|
|
26
|
+
"Field mapping": "Field mapping",
|
|
27
|
+
"Advanced configuration": "Advanced configuration",
|
|
28
|
+
"Usage": "Usage",
|
|
29
|
+
"Redirect URL": "Redirect URL",
|
|
30
|
+
"Check if NocoBase is running on HTTP protocol": "Check if NocoBase is running on HTTP protocol",
|
|
31
|
+
"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",
|
|
32
|
+
"Pass parameters in the authorization code grant exchange": "Pass parameters in the authorization code grant exchange",
|
|
33
|
+
"Method to call the user info endpoint": "Method to call the user info endpoint",
|
|
34
|
+
"Where to put the access token when calling the user info endpoint": "Where to put the access token when calling the user info endpoint",
|
|
35
|
+
"Header": "Header",
|
|
36
|
+
"Body (Use with POST method)": "Body (Use with POST method)",
|
|
37
|
+
"Query parameters (Use with GET method)": "Query parameters (Use with GET method)",
|
|
38
|
+
"Parameter name": "Parameter name",
|
|
39
|
+
"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."
|
|
40
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
{
|
|
2
|
+
"Enable": "Activar",
|
|
3
|
+
"Issuer": "Emisor",
|
|
4
|
+
"Actions": "Acciones",
|
|
5
|
+
"Delete": "Borrar",
|
|
6
|
+
"Edit": "Editar",
|
|
7
|
+
"Button title": "Título del botón",
|
|
8
|
+
"OIDC manager": "Gestor OIDC",
|
|
9
|
+
"OIDC Providers": "Proveedores OIDC",
|
|
10
|
+
"Provider name": "Nombre",
|
|
11
|
+
"Client id": "Id de cliente",
|
|
12
|
+
"Client secret": "Secreto del cliente",
|
|
13
|
+
"Openid configuration": "Configuración Openid",
|
|
14
|
+
"Authorization endpoint": "Endpoint de autorización ",
|
|
15
|
+
"Access token endpoint": "Endpoint de token de acceso",
|
|
16
|
+
"JWKS endpoint": "Endpoint de JWKS",
|
|
17
|
+
"Userinfo endpoint": "Userinfo endpoint",
|
|
18
|
+
"Redirect url": "Redirect url",
|
|
19
|
+
"Logout endpoint": "Endpoint de cierre de sesión",
|
|
20
|
+
"Id token sign alg": "Id token sign alg",
|
|
21
|
+
"Add provider": "Añadir Proveedor",
|
|
22
|
+
"Edit provider": "Editar Proveedor",
|
|
23
|
+
"Delete provider": "Borrar Proveedor",
|
|
24
|
+
"Sign in button name, which will be displayed on the sign in page": "Nombre del botón de inicio de sesión, que se mostrará en la página de inicio de sesión"
|
|
25
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
{
|
|
2
|
+
"Enable": "Activer",
|
|
3
|
+
"Issuer": "Issuer",
|
|
4
|
+
"OIDC manager": "OIDC manager",
|
|
5
|
+
"OIDC Providers": "OIDC Providers",
|
|
6
|
+
"Provider name": "Nom",
|
|
7
|
+
"Client id": "Client id",
|
|
8
|
+
"Client secret": "Client secret",
|
|
9
|
+
"Openid configuration": "Openid configuration",
|
|
10
|
+
"Authorization endpoint": "Authorization endpoint",
|
|
11
|
+
"Access token endpoint": "Access token endpoint",
|
|
12
|
+
"JWKS endpoint": "JWKS endpoint",
|
|
13
|
+
"Userinfo endpoint": "Userinfo endpoint",
|
|
14
|
+
"Redirect url": "Redirect url",
|
|
15
|
+
"Logout endpoint": "Logout endpoint",
|
|
16
|
+
"Id token sign alg": "Id token sign alg",
|
|
17
|
+
"Add provider": "Ajouter",
|
|
18
|
+
"Edit provider": "Modifier",
|
|
19
|
+
"Delete provider": "Supprimer",
|
|
20
|
+
"Sign in button name, which will be displayed on the sign in page": "Nom du bouton de connexion, qui sera affiché sur la page de connexion"
|
|
21
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
{
|
|
2
|
+
"Enable": "활성화",
|
|
3
|
+
"Actions": "작업",
|
|
4
|
+
"Delete": "삭제",
|
|
5
|
+
"Edit": "편집",
|
|
6
|
+
"Copied": "복사됨",
|
|
7
|
+
"Field Map": "필드 매핑",
|
|
8
|
+
"id_token signed response algorithm": "id_token 서명 응답 알고리즘",
|
|
9
|
+
"Use this field to bind the user": "이 필드를 사용하여 사용자를 바인딩합니다",
|
|
10
|
+
"Sign up automatically when the user does not exist": "사용자가 존재하지 않을 때 자동으로 가입",
|
|
11
|
+
"Username must be 2-16 characters in length (excluding @.<>\"'/)": "사용자 이름은 2-16 자여야합니다 (@.<>\"'/ 제외)",
|
|
12
|
+
"User not found": "사용자를 찾을 수 없음",
|
|
13
|
+
"Basic configuration": "기본 설정",
|
|
14
|
+
"Field mapping": "필드 매핑",
|
|
15
|
+
"Advanced configuration": "고급 설정",
|
|
16
|
+
"Usage": "사용 방법",
|
|
17
|
+
"Redirect URL": "리디렉션 URL",
|
|
18
|
+
"Check if NocoBase is running on HTTP protocol": "NocoBase가 HTTP 프로토콜에서 실행 중인지 확인",
|
|
19
|
+
"The port number of the NocoBase service if it is not 80 or 443": "NocoBase 서비스의 포트 번호, 기본값은 443/80",
|
|
20
|
+
"Pass parameters in the authorization code grant exchange": "권한 부여 코드 교환 중에 매개 변수를 전달",
|
|
21
|
+
"Method to call the user info endpoint": "사용자 정보 엔드포인트를 호출하는 방법",
|
|
22
|
+
"Where to put the access token when calling the user info endpoint": "사용자 정보 엔드포인트를 호출할 때 access_token을 어디에 두어야 하는지",
|
|
23
|
+
"Header": "헤더 (기본값)",
|
|
24
|
+
"Body (Use with POST method)": "바디 (POST 방식과 함께 사용)",
|
|
25
|
+
"Query parameters (Use with GET method)": "쿼리 매개 변수 (GET 방식과 함께 사용)",
|
|
26
|
+
"Parameter name": "매개 변수 이름",
|
|
27
|
+
"The state token helps prevent CSRF attacks. It's recommended to leave it blank for automatic random generation.": "상태 토큰은 CSRF 공격을 방지하는 데 도움이 됩니다. 자동으로 무작위로 생성하려면 비워 두는 것이 좋습니다."
|
|
28
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
{
|
|
2
|
+
"Enable": "Habilitar",
|
|
3
|
+
"Issuer": "Emissor",
|
|
4
|
+
"OIDC manager": "Gerenciador OIDC",
|
|
5
|
+
"OIDC Providers": "Provedores OIDC",
|
|
6
|
+
"Provider name": "Nome do provedor",
|
|
7
|
+
"Client id": "ID do cliente",
|
|
8
|
+
"Client secret": "Segredo do cliente",
|
|
9
|
+
"Openid configuration": "Configuração OpenID",
|
|
10
|
+
"Authorization endpoint": "Endpoint de autorização",
|
|
11
|
+
"Access token endpoint": "Endpoint de token de acesso",
|
|
12
|
+
"JWKS endpoint": "Endpoint JWKS",
|
|
13
|
+
"Userinfo endpoint": "Endpoint de informações do usuário",
|
|
14
|
+
"Redirect url": "URL de redirecionamento",
|
|
15
|
+
"Logout endpoint": "Endpoint de logout",
|
|
16
|
+
"Id token sign alg": "Algoritmo de assinatura do token de ID",
|
|
17
|
+
"Add provider": "Adicionar",
|
|
18
|
+
"Edit provider": "Editar",
|
|
19
|
+
"Delete provider": "Excluir",
|
|
20
|
+
"Sign in button name, which will be displayed on the sign in page": "Nome do botão de login, que será exibido na página de login"
|
|
21
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
{
|
|
2
|
+
"Enable": "启用",
|
|
3
|
+
"Actions": "操作",
|
|
4
|
+
"Delete": "删除",
|
|
5
|
+
"Edit": "编辑",
|
|
6
|
+
"Copied": "已复制",
|
|
7
|
+
"Field Map": "字段映射",
|
|
8
|
+
"id_token signed response algorithm": "id_token签名算法",
|
|
9
|
+
"Use this field to bind the user": "使用此字段绑定用户",
|
|
10
|
+
"Sign up automatically when the user does not exist": "用户不存在时自动注册",
|
|
11
|
+
"Username must be 2-16 characters in length (excluding @.<>\"'/)": "用户名必须为2-16个字符并且不包含@.<>\"'/)",
|
|
12
|
+
"User not found": "用户不存在",
|
|
13
|
+
"Basic configuration": "基础配置",
|
|
14
|
+
"Field mapping": "字段映射",
|
|
15
|
+
"Advanced configuration": "高级配置",
|
|
16
|
+
"Usage": "使用",
|
|
17
|
+
"Redirect URL": "回调 URL",
|
|
18
|
+
"Check if NocoBase is running on HTTP protocol": "NocoBase 应用为HTTP协议时勾选",
|
|
19
|
+
"The port number of the NocoBase service if it is not 80 or 443": "NocoBase 应用端口,默认 443/80",
|
|
20
|
+
"Pass parameters in the authorization code grant exchange": "使用 code 交换 token 时需要传递的参数",
|
|
21
|
+
"Method to call the user info endpoint": "访问获取用户信息的 API 的 HTTP 方法",
|
|
22
|
+
"Where to put the access token when calling the user info endpoint": "访问获取用户信息的 API 时 access_token 的传递方式",
|
|
23
|
+
"Header": "请求头 (Header, 默认)",
|
|
24
|
+
"Body (Use with POST method)": "请求体(Body, 配合 POST 方法使用)",
|
|
25
|
+
"Query parameters (Use with GET method)": "请求 URL 参数(Query, 配合 GET 方法使用)",
|
|
26
|
+
"Parameter name": "参数名",
|
|
27
|
+
"The state token helps prevent CSRF attacks. It's recommended to leave it blank for automatic random generation.": "state token 用于防止 CSRF 攻击,建议留空使用自动生成的随机值。"
|
|
28
|
+
}
|
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
import { Database } from '@nocobase/database';
|
|
2
|
+
import { MockServer, createMockServer } from '@nocobase/test';
|
|
3
|
+
import { vi } from 'vitest';
|
|
4
|
+
import { authType } from '../../constants';
|
|
5
|
+
import { OIDCAuth } from '../oidc-auth';
|
|
6
|
+
import { AppSupervisor } from '@nocobase/server';
|
|
7
|
+
|
|
8
|
+
describe('oidc', () => {
|
|
9
|
+
let app: MockServer;
|
|
10
|
+
let db: Database;
|
|
11
|
+
let agent;
|
|
12
|
+
let authenticator;
|
|
13
|
+
|
|
14
|
+
beforeAll(async () => {
|
|
15
|
+
app = await createMockServer({
|
|
16
|
+
plugins: ['users', 'auth', 'oidc'],
|
|
17
|
+
});
|
|
18
|
+
db = app.db;
|
|
19
|
+
agent = app.agent();
|
|
20
|
+
|
|
21
|
+
const authenticatorRepo = db.getRepository('authenticators');
|
|
22
|
+
authenticator = await authenticatorRepo.create({
|
|
23
|
+
values: {
|
|
24
|
+
name: 'oidc-auth',
|
|
25
|
+
authType: authType,
|
|
26
|
+
enabled: 1,
|
|
27
|
+
options: {
|
|
28
|
+
oidc: {
|
|
29
|
+
issuer: '',
|
|
30
|
+
clientId: '',
|
|
31
|
+
clientSecret: '',
|
|
32
|
+
},
|
|
33
|
+
},
|
|
34
|
+
},
|
|
35
|
+
});
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
afterAll(async () => {
|
|
39
|
+
await app.destroy();
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
afterEach(async () => {
|
|
43
|
+
vi.restoreAllMocks();
|
|
44
|
+
await db.getRepository('users').destroy({
|
|
45
|
+
truncate: true,
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('should get auth url', async () => {
|
|
50
|
+
agent = app.agent();
|
|
51
|
+
vi.spyOn(OIDCAuth.prototype, 'createOIDCClient').mockResolvedValue({
|
|
52
|
+
authorizationUrl: ({ state }) => state,
|
|
53
|
+
} as any);
|
|
54
|
+
const res = await agent.set('X-Authenticator', 'oidc-auth').resource('oidc').getAuthUrl();
|
|
55
|
+
expect(res.body.data).toBeDefined();
|
|
56
|
+
const search = new URLSearchParams(decodeURIComponent(res.body.data));
|
|
57
|
+
expect(search.get('token')).toBeDefined();
|
|
58
|
+
expect(search.get('name')).toBe('oidc-auth');
|
|
59
|
+
expect(res.headers['set-cookie']).toBeDefined();
|
|
60
|
+
const token = res.headers['set-cookie'][0].split(';')[0].split('=')[1];
|
|
61
|
+
expect(token).toBe(search.get('token'));
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('should not sign in without auto signup', async () => {
|
|
65
|
+
await authenticator.update({
|
|
66
|
+
options: {
|
|
67
|
+
public: {
|
|
68
|
+
autoSignup: false,
|
|
69
|
+
},
|
|
70
|
+
},
|
|
71
|
+
});
|
|
72
|
+
agent = app.agent();
|
|
73
|
+
vi.spyOn(OIDCAuth.prototype, 'createOIDCClient').mockResolvedValue({
|
|
74
|
+
callback: (uri, { code }) => ({
|
|
75
|
+
access_token: 'access_token',
|
|
76
|
+
}),
|
|
77
|
+
userinfo: () => ({
|
|
78
|
+
sub: 'user1',
|
|
79
|
+
}),
|
|
80
|
+
} as any);
|
|
81
|
+
|
|
82
|
+
const res = await agent
|
|
83
|
+
.set('X-Authenticator', 'oidc-auth')
|
|
84
|
+
.set('Cookie', ['nocobase_oidc=token'])
|
|
85
|
+
.get('/auth:signIn?state=token%3Dtoken&name=oidc-auth');
|
|
86
|
+
|
|
87
|
+
expect(res.statusCode).toBe(401);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('should sign in with auto signup', async () => {
|
|
91
|
+
await authenticator.update({
|
|
92
|
+
options: {
|
|
93
|
+
public: {
|
|
94
|
+
autoSignup: true,
|
|
95
|
+
},
|
|
96
|
+
},
|
|
97
|
+
});
|
|
98
|
+
agent = app.agent();
|
|
99
|
+
vi.spyOn(OIDCAuth.prototype, 'createOIDCClient').mockResolvedValue({
|
|
100
|
+
callback: (uri, { code }) => ({
|
|
101
|
+
access_token: 'access_token',
|
|
102
|
+
}),
|
|
103
|
+
userinfo: () => ({
|
|
104
|
+
sub: 'user1',
|
|
105
|
+
}),
|
|
106
|
+
} as any);
|
|
107
|
+
|
|
108
|
+
const res = await agent
|
|
109
|
+
.set('X-Authenticator', 'oidc-auth')
|
|
110
|
+
.set('Cookie', ['nocobase_oidc=token'])
|
|
111
|
+
.get('/auth:signIn?state=token%3Dtoken&name=oidc-auth');
|
|
112
|
+
expect(res.statusCode).toBe(200);
|
|
113
|
+
expect(res.body.data.user).toBeDefined();
|
|
114
|
+
expect(res.body.data.user.nickname).toBe('user1');
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it('should sign in with existed email', async () => {
|
|
118
|
+
await authenticator.update({
|
|
119
|
+
options: {
|
|
120
|
+
oidc: {
|
|
121
|
+
userBindField: 'email',
|
|
122
|
+
},
|
|
123
|
+
public: {
|
|
124
|
+
autoSignup: false,
|
|
125
|
+
},
|
|
126
|
+
},
|
|
127
|
+
});
|
|
128
|
+
const user = await db.getRepository('users').create({
|
|
129
|
+
values: {
|
|
130
|
+
nickname: 'has-email',
|
|
131
|
+
email: 'test@nocobase.com',
|
|
132
|
+
},
|
|
133
|
+
});
|
|
134
|
+
agent = app.agent();
|
|
135
|
+
vi.spyOn(OIDCAuth.prototype, 'createOIDCClient').mockResolvedValue({
|
|
136
|
+
callback: (uri, { code }) => ({
|
|
137
|
+
access_token: 'access_token',
|
|
138
|
+
}),
|
|
139
|
+
userinfo: () => ({
|
|
140
|
+
sub: 'user1',
|
|
141
|
+
email: 'test@nocobase.com',
|
|
142
|
+
}),
|
|
143
|
+
} as any);
|
|
144
|
+
|
|
145
|
+
const res = await agent
|
|
146
|
+
.set('X-Authenticator', 'oidc-auth')
|
|
147
|
+
.set('Cookie', ['nocobase_oidc=token'])
|
|
148
|
+
.get('/auth:signIn?state=token%3Dtoken&name=oidc-auth');
|
|
149
|
+
|
|
150
|
+
expect(res.body.data.user).toBeDefined();
|
|
151
|
+
expect(res.body.data.user.id).toBe(user.id);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it('should sign in with existed username', async () => {
|
|
155
|
+
await authenticator.update({
|
|
156
|
+
options: {
|
|
157
|
+
oidc: {
|
|
158
|
+
userBindField: 'username',
|
|
159
|
+
},
|
|
160
|
+
public: {
|
|
161
|
+
autoSignup: false,
|
|
162
|
+
},
|
|
163
|
+
},
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
const user = await db.getRepository('users').create({
|
|
167
|
+
values: {
|
|
168
|
+
nickname: 'has-username',
|
|
169
|
+
username: 'username',
|
|
170
|
+
},
|
|
171
|
+
});
|
|
172
|
+
agent = app.agent();
|
|
173
|
+
vi.spyOn(OIDCAuth.prototype, 'createOIDCClient').mockResolvedValue({
|
|
174
|
+
callback: (uri, { code }) => ({
|
|
175
|
+
access_token: 'access_token',
|
|
176
|
+
}),
|
|
177
|
+
userinfo: () => ({
|
|
178
|
+
username: 'username',
|
|
179
|
+
sub: 'username',
|
|
180
|
+
}),
|
|
181
|
+
} as any);
|
|
182
|
+
|
|
183
|
+
const res = await agent
|
|
184
|
+
.set('X-Authenticator', 'oidc-auth')
|
|
185
|
+
.set('Cookie', ['nocobase_oidc=token'])
|
|
186
|
+
.get('/auth:signIn?state=token%3Dtoken&name=oidc-auth');
|
|
187
|
+
|
|
188
|
+
expect(res.body.data.user).toBeDefined();
|
|
189
|
+
expect(res.body.data.user.id).toBe(user.id);
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
it('oidc:redirect', async () => {
|
|
193
|
+
vi.spyOn(OIDCAuth.prototype, 'signIn').mockResolvedValue({
|
|
194
|
+
user: {} as any,
|
|
195
|
+
token: 'test-token',
|
|
196
|
+
});
|
|
197
|
+
const res = await agent.get(`/oidc:redirect?state=${encodeURIComponent('name=oidc-auth&app=main')}`);
|
|
198
|
+
expect(res.statusCode).toBe(302);
|
|
199
|
+
expect(res.headers.location).toBe(`/admin?authenticator=oidc-auth&token=test-token`);
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
it('oidc:redirect, sub app', async () => {
|
|
203
|
+
vi.spyOn(OIDCAuth.prototype, 'signIn').mockResolvedValue({
|
|
204
|
+
user: {} as any,
|
|
205
|
+
token: 'test-token',
|
|
206
|
+
});
|
|
207
|
+
vi.spyOn(AppSupervisor, 'getInstance').mockReturnValue({
|
|
208
|
+
runningMode: 'multiple',
|
|
209
|
+
} as any);
|
|
210
|
+
const res = await agent.get(`/oidc:redirect?state=${encodeURIComponent('name=oidc-auth&app=sub')}`);
|
|
211
|
+
expect(res.statusCode).toBe(302);
|
|
212
|
+
expect(res.headers.location).toBe(`/apps/sub/admin?authenticator=oidc-auth&token=test-token`);
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
it('oidc:redirect, error', async () => {
|
|
216
|
+
vi.spyOn(OIDCAuth.prototype, 'signIn').mockRejectedValue(new Error('test error'));
|
|
217
|
+
const res = await agent.get(`/oidc:redirect?state=${encodeURIComponent('name=oidc-auth&app=main')}`);
|
|
218
|
+
expect(res.statusCode).toBe(302);
|
|
219
|
+
expect(res.headers.location).toBe(`/signin?redirect=/admin&authenticator=oidc-auth&error=test%20error`);
|
|
220
|
+
});
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
test('field mapping', () => {
|
|
224
|
+
const auth = new OIDCAuth({
|
|
225
|
+
authenticator: null,
|
|
226
|
+
ctx: {
|
|
227
|
+
db: {
|
|
228
|
+
getCollection: () => ({}),
|
|
229
|
+
} as any,
|
|
230
|
+
} as any,
|
|
231
|
+
options: {
|
|
232
|
+
oidc: {
|
|
233
|
+
fieldMap: [
|
|
234
|
+
{
|
|
235
|
+
source: 'username',
|
|
236
|
+
target: 'nickname',
|
|
237
|
+
},
|
|
238
|
+
],
|
|
239
|
+
},
|
|
240
|
+
},
|
|
241
|
+
});
|
|
242
|
+
const userInfo = auth.mapField({
|
|
243
|
+
sub: 1,
|
|
244
|
+
username: 'user1',
|
|
245
|
+
});
|
|
246
|
+
expect(userInfo).toEqual({
|
|
247
|
+
sub: 1,
|
|
248
|
+
username: 'user1',
|
|
249
|
+
nickname: 'user1',
|
|
250
|
+
});
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
test('getExchangeBody', () => {
|
|
254
|
+
const auth = new OIDCAuth({
|
|
255
|
+
ctx: {
|
|
256
|
+
db: {
|
|
257
|
+
getCollection: () => ({}),
|
|
258
|
+
},
|
|
259
|
+
},
|
|
260
|
+
options: {
|
|
261
|
+
oidc: {
|
|
262
|
+
clientId: 'test_client_id',
|
|
263
|
+
clientSecret: 'test_client_secret',
|
|
264
|
+
exchangeBodyKeys: [
|
|
265
|
+
{
|
|
266
|
+
paramName: 'client_id',
|
|
267
|
+
optionsKey: 'clientId',
|
|
268
|
+
enabled: true,
|
|
269
|
+
},
|
|
270
|
+
{
|
|
271
|
+
paramName: 'client_secret',
|
|
272
|
+
optionsKey: 'clientSecret',
|
|
273
|
+
enabled: false,
|
|
274
|
+
},
|
|
275
|
+
],
|
|
276
|
+
},
|
|
277
|
+
},
|
|
278
|
+
} as any);
|
|
279
|
+
const body = auth.getExchangeBody();
|
|
280
|
+
expect(body).toMatchObject({
|
|
281
|
+
client_id: 'test_client_id',
|
|
282
|
+
});
|
|
283
|
+
});
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { Context, Next } from '@nocobase/actions';
|
|
2
|
+
import { OIDCAuth } from '../oidc-auth';
|
|
3
|
+
import { nanoid } from 'nanoid';
|
|
4
|
+
import { cookieName } from '../../constants';
|
|
5
|
+
|
|
6
|
+
export const getAuthUrl = async (ctx: Context, next: Next) => {
|
|
7
|
+
const redirect = ctx.action.params.values?.redirect || '';
|
|
8
|
+
const app = ctx.app.name;
|
|
9
|
+
const auth = ctx.auth as OIDCAuth;
|
|
10
|
+
const client = await auth.createOIDCClient();
|
|
11
|
+
const { scope, stateToken } = auth.getOptions();
|
|
12
|
+
const token = stateToken || nanoid(15);
|
|
13
|
+
ctx.cookies.set(cookieName, token, {
|
|
14
|
+
httpOnly: true,
|
|
15
|
+
domain: ctx.hostname,
|
|
16
|
+
});
|
|
17
|
+
ctx.body = client.authorizationUrl({
|
|
18
|
+
response_type: 'code',
|
|
19
|
+
scope: scope || 'openid email profile',
|
|
20
|
+
redirect_uri: `${auth.getRedirectUri()}`,
|
|
21
|
+
state: encodeURIComponent(`token=${token}&name=${ctx.headers['x-authenticator']}&app=${app}&redirect=${redirect}`),
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
return next();
|
|
25
|
+
};
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { Context, Next } from '@nocobase/actions';
|
|
2
|
+
import { AppSupervisor } from '@nocobase/server';
|
|
3
|
+
import { OIDCAuth } from '../oidc-auth';
|
|
4
|
+
|
|
5
|
+
export const redirect = async (ctx: Context, next: Next) => {
|
|
6
|
+
const {
|
|
7
|
+
params: { state },
|
|
8
|
+
} = ctx.action;
|
|
9
|
+
const search = new URLSearchParams(decodeURIComponent(state));
|
|
10
|
+
const authenticator = search.get('name');
|
|
11
|
+
const appName = search.get('app');
|
|
12
|
+
const redirect = search.get('redirect') || '/admin';
|
|
13
|
+
let prefix = process.env.APP_PUBLIC_PATH || '';
|
|
14
|
+
if (appName && appName !== 'main') {
|
|
15
|
+
const appSupervisor = AppSupervisor.getInstance();
|
|
16
|
+
if (appSupervisor?.runningMode !== 'single') {
|
|
17
|
+
prefix += `apps/${appName}`;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
const auth = (await ctx.app.authManager.get(authenticator, ctx)) as OIDCAuth;
|
|
21
|
+
if (prefix.endsWith('/')) {
|
|
22
|
+
prefix = prefix.slice(0, -1);
|
|
23
|
+
}
|
|
24
|
+
try {
|
|
25
|
+
const { token } = await auth.signIn();
|
|
26
|
+
ctx.redirect(`${prefix}${redirect}?authenticator=${authenticator}&token=${token}`);
|
|
27
|
+
} catch (error) {
|
|
28
|
+
ctx.logger.error('OIDC auth error', { error });
|
|
29
|
+
ctx.redirect(`${prefix}/signin?redirect=${redirect}&authenticator=${authenticator}&error=${error.message}`);
|
|
30
|
+
}
|
|
31
|
+
await next();
|
|
32
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { default } from './plugin';
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import { AuthConfig, BaseAuth } from '@nocobase/auth';
|
|
2
|
+
import { AuthModel } from '@nocobase/plugin-auth';
|
|
3
|
+
import { Issuer } from 'openid-client';
|
|
4
|
+
import { cookieName, logoutCookieName } from '../constants';
|
|
5
|
+
|
|
6
|
+
export class OIDCAuth extends BaseAuth {
|
|
7
|
+
constructor(config: AuthConfig) {
|
|
8
|
+
const { ctx } = config;
|
|
9
|
+
super({
|
|
10
|
+
...config,
|
|
11
|
+
userCollection: ctx.db.getCollection('users'),
|
|
12
|
+
});
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
getRedirectUri() {
|
|
16
|
+
const ctx = this.ctx;
|
|
17
|
+
const { http, port } = this.getOptions();
|
|
18
|
+
const protocol = http ? 'http' : 'https';
|
|
19
|
+
const host = port ? `${ctx.hostname}${port ? `:${port}` : ''}` : ctx.host;
|
|
20
|
+
return `${protocol}://${host}${process.env.API_BASE_PATH}oidc:redirect`;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
getOptions() {
|
|
24
|
+
return this.options?.oidc || {};
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
getExchangeBody() {
|
|
28
|
+
const options = this.getOptions();
|
|
29
|
+
const { exchangeBodyKeys } = options;
|
|
30
|
+
if (!exchangeBodyKeys) {
|
|
31
|
+
return {};
|
|
32
|
+
}
|
|
33
|
+
const body = {};
|
|
34
|
+
exchangeBodyKeys
|
|
35
|
+
.filter((item: { enabled: boolean }) => item.enabled)
|
|
36
|
+
.forEach((item: { paramName: string; optionsKey: string }) => {
|
|
37
|
+
const name = item.paramName || item.optionsKey;
|
|
38
|
+
body[name] = options[item.optionsKey];
|
|
39
|
+
});
|
|
40
|
+
return body;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
mapField(userInfo: { [source: string]: any }) {
|
|
44
|
+
const { fieldMap } = this.getOptions();
|
|
45
|
+
if (!fieldMap) {
|
|
46
|
+
return userInfo;
|
|
47
|
+
}
|
|
48
|
+
fieldMap.forEach((item: { source: string; target: string }) => {
|
|
49
|
+
const { source, target } = item;
|
|
50
|
+
if (userInfo[source]) {
|
|
51
|
+
userInfo[target] = userInfo[source];
|
|
52
|
+
}
|
|
53
|
+
});
|
|
54
|
+
return userInfo;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async createOIDCClient() {
|
|
58
|
+
const { issuer, clientId, clientSecret, idTokenSignedResponseAlg } = this.getOptions();
|
|
59
|
+
const oidc = await Issuer.discover(issuer);
|
|
60
|
+
return new oidc.Client({
|
|
61
|
+
client_id: clientId,
|
|
62
|
+
client_secret: clientSecret,
|
|
63
|
+
id_token_signed_response_alg: idTokenSignedResponseAlg || 'RS256',
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async validate() {
|
|
68
|
+
const ctx = this.ctx;
|
|
69
|
+
const { params: values } = ctx.action;
|
|
70
|
+
const { userInfoMethod = 'GET', accessTokenVia = 'header', stateToken } = this.getOptions();
|
|
71
|
+
const token = stateToken || ctx.cookies.get(cookieName);
|
|
72
|
+
const search = new URLSearchParams(decodeURIComponent(values.state));
|
|
73
|
+
if (search.get('token') !== token) {
|
|
74
|
+
ctx.logger.error('nocobase_oidc state mismatch', { method: 'validate' });
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
const client = await this.createOIDCClient();
|
|
78
|
+
const tokens = await client.callback(
|
|
79
|
+
this.getRedirectUri(),
|
|
80
|
+
{
|
|
81
|
+
code: values.code,
|
|
82
|
+
iss: values.iss,
|
|
83
|
+
},
|
|
84
|
+
{},
|
|
85
|
+
{ exchangeBody: this.getExchangeBody() },
|
|
86
|
+
);
|
|
87
|
+
const userInfo: { [key: string]: any } = await client.userinfo(tokens, {
|
|
88
|
+
method: userInfoMethod,
|
|
89
|
+
via: accessTokenVia !== 'query' ? accessTokenVia : 'header',
|
|
90
|
+
params:
|
|
91
|
+
accessTokenVia === 'query'
|
|
92
|
+
? {
|
|
93
|
+
access_token: tokens.access_token,
|
|
94
|
+
}
|
|
95
|
+
: {},
|
|
96
|
+
});
|
|
97
|
+
const mappedUserInfo = this.mapField(userInfo);
|
|
98
|
+
const { nickname, username, name, sub, email, phone, ...rest } = mappedUserInfo;
|
|
99
|
+
const authenticator = this.authenticator as AuthModel;
|
|
100
|
+
const userFields = {
|
|
101
|
+
username: username ?? null,
|
|
102
|
+
nickname: nickname || name || username || sub,
|
|
103
|
+
email: email ?? null,
|
|
104
|
+
phone: phone ?? null,
|
|
105
|
+
...rest,
|
|
106
|
+
};
|
|
107
|
+
let user = await authenticator.findUser(sub);
|
|
108
|
+
if (user) {
|
|
109
|
+
// Update user fields
|
|
110
|
+
this.userRepository.update({
|
|
111
|
+
filter: {
|
|
112
|
+
id: user.id,
|
|
113
|
+
},
|
|
114
|
+
values: userFields,
|
|
115
|
+
});
|
|
116
|
+
return user;
|
|
117
|
+
}
|
|
118
|
+
// Bind existed user
|
|
119
|
+
const { userBindField = 'email' } = this.getOptions();
|
|
120
|
+
if (userBindField === 'email' && email) {
|
|
121
|
+
user = await this.userRepository.findOne({
|
|
122
|
+
filter: { email },
|
|
123
|
+
});
|
|
124
|
+
} else if (userBindField === 'username' && username) {
|
|
125
|
+
user = await this.userRepository.findOne({
|
|
126
|
+
filter: { username },
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
if (user) {
|
|
130
|
+
// Update user fields
|
|
131
|
+
this.userRepository.update({
|
|
132
|
+
filter: {
|
|
133
|
+
id: user.id,
|
|
134
|
+
},
|
|
135
|
+
values: userFields,
|
|
136
|
+
});
|
|
137
|
+
await authenticator.addUser(user.id, {
|
|
138
|
+
through: {
|
|
139
|
+
uuid: sub,
|
|
140
|
+
},
|
|
141
|
+
});
|
|
142
|
+
return user;
|
|
143
|
+
}
|
|
144
|
+
// Create new user
|
|
145
|
+
const { autoSignup } = this.options?.public || {};
|
|
146
|
+
if (!autoSignup) {
|
|
147
|
+
throw new Error('User not found');
|
|
148
|
+
}
|
|
149
|
+
if (username && !this.validateUsername(username)) {
|
|
150
|
+
throw new Error('Username must be 2-16 characters in length (excluding @.<>"\'/)');
|
|
151
|
+
}
|
|
152
|
+
return await authenticator.newUser(sub, userFields);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
async signOut(): Promise<any> {
|
|
156
|
+
const { issuer, clientId, logout } = this.getOptions();
|
|
157
|
+
const oidc = await Issuer.discover(issuer);
|
|
158
|
+
if (logout && oidc.metadata.end_session_endpoint) {
|
|
159
|
+
const logoutUrl = new URL(oidc.metadata.end_session_endpoint);
|
|
160
|
+
logoutUrl.searchParams.set('client_id', clientId);
|
|
161
|
+
//logoutUrl.searchParams.set('id_token_hint', ???);
|
|
162
|
+
this.ctx.cookies.set(logoutCookieName, logoutUrl.toString(), {
|
|
163
|
+
httpOnly: false,
|
|
164
|
+
domain: this.ctx.hostname,
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
return super.signOut();
|
|
168
|
+
}
|
|
169
|
+
}
|