cabloy 5.1.242 → 5.1.244
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/.cabloy-version +1 -1
- package/.github/workflows/vona-test-sqlite3.yml +1 -1
- package/CHANGELOG.md +26 -0
- package/package.json +1 -1
- package/repo-agent-governance/managed-assets.json +6 -6
- package/repo-agent-governance/policies/repository.md +1 -0
- package/repo-agent-governance/skills/cabloy-frontend-scaffold/SKILL.md +4 -2
- package/repo-docs/backend/auth-guide.md +48 -0
- package/repo-docs/backend/demonstration-mode-guide.md +23 -3
- package/repo-docs/backend/openapi-guide.md +1 -1
- package/repo-docs/frontend/behavior-guide.md +12 -0
- package/repo-e2e/specs/home-user-account.spec.ts +98 -3
- package/vona/env/.env +2 -0
- package/vona/packages-cli/cli/package.json +1 -1
- package/vona/packages-cli/cli-set-api/package.json +1 -1
- package/vona/packages-cli/cli-set-api/src/lib/bean/cli.bin.build.ts +11 -0
- package/vona/packages-cli/cli-set-api/test/binBuildFreshInputs.test.ts +152 -0
- package/vona/packages-utils/babel-plugin-vona-bean-module/package.json +1 -1
- package/vona/packages-utils/babel-plugin-vona-bean-module/src/index.test.ts +27 -0
- package/vona/packages-utils/babel-plugin-vona-bean-module/src/index.ts +1 -0
- package/vona/packages-vona/vona/package.json +1 -1
- package/vona/packages-vona/vona-core/package.json +1 -1
- package/vona/packages-vona/vona-core/src/lib/core/application.ts +2 -2
- package/vona/packages-vona/vona-mock/package.json +1 -1
- package/vona/src/suite/a-demo/modules/demo-demonstration/src/bean/guard.demonstration.ts +8 -1
- package/vona/src/suite/a-demo/modules/demo-demonstration/test/guardDemonstration.test.ts +79 -3
- package/vona/src/suite/a-home/modules/home-user/test/passportSession.test.ts +380 -0
- package/vona/src/suite-vendor/a-cabloy/modules/a-rbac/package.json +1 -1
- package/vona/src/suite-vendor/a-cabloy/package.json +2 -2
- package/vona/src/suite-vendor/a-vona/modules/a-core/package.json +1 -1
- package/vona/src/suite-vendor/a-vona/modules/a-openapiutils/package.json +1 -1
- package/vona/src/suite-vendor/a-vona/modules/a-openapiutils/src/lib/decorator/mcp.ts +2 -2
- package/vona/src/suite-vendor/a-vona/modules/a-permission/package.json +1 -1
- package/vona/src/suite-vendor/a-vona/modules/a-user/package.json +1 -1
- package/vona/src/suite-vendor/a-vona/modules/a-user/src/bean/bean.passport.ts +82 -20
- package/vona/src/suite-vendor/a-vona/modules/a-user/src/config/config.ts +4 -1
- package/vona/src/suite-vendor/a-vona/modules/a-user/src/lib/auth.ts +20 -1
- package/vona/src/suite-vendor/a-vona/modules/a-user/src/main.ts +3 -0
- package/vona/src/suite-vendor/a-vona/modules/a-user/src/service/authTokenAdapter.ts +4 -1
- package/vona/src/suite-vendor/a-vona/modules/a-user/src/service/redisToken.ts +6 -2
- package/vona/src/suite-vendor/a-vona/modules/a-user/src/types/auth.ts +8 -0
- package/vona/src/suite-vendor/a-vona/modules/a-user/src/types/passport.ts +1 -0
- package/vona/src/suite-vendor/a-vona/package.json +1 -1
- package/zova/src/suite/a-home/modules/home-layoutadmin/src/component/layoutAdmin/render.user.tsx +5 -4
- package/zova/src/suite/a-home/modules/home-layoutweb/src/component/layoutWeb/render.user.tsx +5 -4
|
@@ -14,3 +14,30 @@ test('excludes timestamped generated Vona roots from bean module transforms', ()
|
|
|
14
14
|
);
|
|
15
15
|
assert.equal(isValidBeanModuleFile('/project/vona/src/module/foo/src/index.ts'), true);
|
|
16
16
|
});
|
|
17
|
+
|
|
18
|
+
for (const relativePath of [
|
|
19
|
+
'src/boot/app/index.ts',
|
|
20
|
+
'.zova/app/index.ts',
|
|
21
|
+
'src/backend/view.tsx',
|
|
22
|
+
'.vona/app.ts',
|
|
23
|
+
]) {
|
|
24
|
+
for (const separator of ['/', '\\']) {
|
|
25
|
+
test(`excludes ${relativePath} with ${JSON.stringify(separator)} separators`, () => {
|
|
26
|
+
const fileName = `/project/vona-bin-build-fresh-a1b2c3/${relativePath}`.replaceAll(
|
|
27
|
+
'/',
|
|
28
|
+
separator,
|
|
29
|
+
);
|
|
30
|
+
assert.equal(isValidBeanModuleFile(fileName), false);
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
for (const separator of ['/', '\\']) {
|
|
36
|
+
test(`includes module source with ${JSON.stringify(separator)} separators`, () => {
|
|
37
|
+
const fileName = '/project/vona/src/module/test-fixture/src/service/main.ts'.replaceAll(
|
|
38
|
+
'/',
|
|
39
|
+
separator,
|
|
40
|
+
);
|
|
41
|
+
assert.equal(isValidBeanModuleFile(fileName), true);
|
|
42
|
+
});
|
|
43
|
+
}
|
|
@@ -95,6 +95,7 @@ function __createDecoratorNode(beanInfo) {
|
|
|
95
95
|
|
|
96
96
|
export function isValidBeanModuleFile(fileName?: string | null) {
|
|
97
97
|
if (!fileName) return false;
|
|
98
|
+
fileName = fileName.replace(/\\/g, '/');
|
|
98
99
|
if (/[\\/].vona-test-\d{14}-[a-f0-9]{8}[\\/]/.test(fileName)) return false;
|
|
99
100
|
return !['src/boot/app/', '.zova/app/', 'src/backend/', '.vona/'].some(item => {
|
|
100
101
|
return fileName.includes(item);
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "vona",
|
|
3
|
-
"version": "5.1.
|
|
3
|
+
"version": "5.1.123",
|
|
4
4
|
"gitHead": "a79189b882c17af5911573896a781bbb0046d37d",
|
|
5
5
|
"description": "Vona is an intuitive, elegant and powerful Node.js framework for rapidly developing enterprise applications of any size",
|
|
6
6
|
"keywords": [
|
|
@@ -30,7 +30,6 @@ export interface IGuardOptionsDemonstration extends IDecoratorGuardOptionsGlobal
|
|
|
30
30
|
methodsForWrite,
|
|
31
31
|
usernameWhitelist: parseUsernameWhitelist(app.meta.env.DEMONSTRATION_USERNAME_WHITELIST),
|
|
32
32
|
ignore: [
|
|
33
|
-
'/home/user/passport/logout',
|
|
34
33
|
'/home/user/passport/register',
|
|
35
34
|
'/home/user/passport/login',
|
|
36
35
|
'/home/user/passport/login/:module/:providerName/:clientName?',
|
|
@@ -59,6 +58,14 @@ export class GuardDemonstration extends BeanBase implements IGuardExecute {
|
|
|
59
58
|
return next();
|
|
60
59
|
}
|
|
61
60
|
|
|
61
|
+
if (
|
|
62
|
+
method === 'POST' &&
|
|
63
|
+
this.ctx.route?.routePathRaw === '/home/user/passport/logout' &&
|
|
64
|
+
this.bean.passport.authTokenMode === 'session'
|
|
65
|
+
) {
|
|
66
|
+
return next();
|
|
67
|
+
}
|
|
68
|
+
|
|
62
69
|
const user = this.bean.passport.currentUser;
|
|
63
70
|
if (
|
|
64
71
|
this.bean.passport.isAuthenticated &&
|
|
@@ -8,6 +8,7 @@ const guardBeanName = 'demo-demonstration.guard.demonstration';
|
|
|
8
8
|
const businessRoute = {
|
|
9
9
|
controllerBeanFullName: 'test.controller.business',
|
|
10
10
|
action: 'write',
|
|
11
|
+
routePathRaw: '/test/business/write',
|
|
11
12
|
};
|
|
12
13
|
|
|
13
14
|
function guard() {
|
|
@@ -29,7 +30,7 @@ function isGuardEnabledForRoute(routePathRaw: string, enable = true): boolean {
|
|
|
29
30
|
return app.bean.onion.checkOnionOptionsEnabled({ ...options, enable }, routePathRaw);
|
|
30
31
|
}
|
|
31
32
|
|
|
32
|
-
describe('guardDemonstration.test.ts', () => {
|
|
33
|
+
describe('guardDemonstration.test.ts', { concurrency: false }, () => {
|
|
33
34
|
it('parses a trimmed, deduplicated username whitelist and exposes localized errors', async () => {
|
|
34
35
|
assert.deepEqual(parseUsernameWhitelist(' admin, demo, ,admin '), ['admin', 'demo']);
|
|
35
36
|
assert.deepEqual(parseUsernameWhitelist(undefined), []);
|
|
@@ -101,11 +102,84 @@ describe('guardDemonstration.test.ts', () => {
|
|
|
101
102
|
});
|
|
102
103
|
});
|
|
103
104
|
|
|
104
|
-
it('
|
|
105
|
+
it('allows non-whitelisted logout only in session auth-token mode', async t => {
|
|
106
|
+
await app.bean.executor.mockCtx(async () => {
|
|
107
|
+
const passport = app.bean.passport;
|
|
108
|
+
await passport.signinMock('admin');
|
|
109
|
+
try {
|
|
110
|
+
setRequest('POST', { ...businessRoute, routePathRaw: '/home/user/passport/logout' });
|
|
111
|
+
for (const authTokenMode of ['auth', 'session'] as const) {
|
|
112
|
+
const getter = t.mock.getter(passport, 'authTokenMode', () => authTokenMode);
|
|
113
|
+
try {
|
|
114
|
+
let continued = false;
|
|
115
|
+
const [result, err] = await catchError(() =>
|
|
116
|
+
guard().execute(
|
|
117
|
+
{ enable: true, methodsForWrite: ['POST'], usernameWhitelist: [] } as any,
|
|
118
|
+
async () => {
|
|
119
|
+
continued = true;
|
|
120
|
+
return true;
|
|
121
|
+
},
|
|
122
|
+
),
|
|
123
|
+
);
|
|
124
|
+
if (authTokenMode === 'session') {
|
|
125
|
+
assert.ifError(err);
|
|
126
|
+
assert.equal(result, true);
|
|
127
|
+
assert.equal(continued, true);
|
|
128
|
+
} else {
|
|
129
|
+
assert.equal(err?.code, 'demo-demonstration:1001');
|
|
130
|
+
assert.equal(err?.status, 403);
|
|
131
|
+
assert.equal(continued, false);
|
|
132
|
+
}
|
|
133
|
+
} finally {
|
|
134
|
+
getter.mock.restore();
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
} finally {
|
|
138
|
+
await passport.signout();
|
|
139
|
+
}
|
|
140
|
+
});
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it('keeps other writes protected in session mode', async t => {
|
|
144
|
+
await app.bean.executor.mockCtx(async () => {
|
|
145
|
+
const passport = app.bean.passport;
|
|
146
|
+
await passport.signinMock('admin');
|
|
147
|
+
try {
|
|
148
|
+
const getter = t.mock.getter(passport, 'authTokenMode', () => 'session' as const);
|
|
149
|
+
try {
|
|
150
|
+
for (const [method, routePathRaw] of [
|
|
151
|
+
['POST', businessRoute.routePathRaw],
|
|
152
|
+
['DELETE', '/home/user/passport/logout'],
|
|
153
|
+
['POST', '/home/user/passport/logout/'],
|
|
154
|
+
]) {
|
|
155
|
+
setRequest(method, { ...businessRoute, routePathRaw });
|
|
156
|
+
let continued = false;
|
|
157
|
+
const [_, err] = await catchError(() =>
|
|
158
|
+
guard().execute(
|
|
159
|
+
{ enable: true, methodsForWrite: ['POST', 'DELETE'], usernameWhitelist: [] } as any,
|
|
160
|
+
async () => {
|
|
161
|
+
continued = true;
|
|
162
|
+
return true;
|
|
163
|
+
},
|
|
164
|
+
),
|
|
165
|
+
);
|
|
166
|
+
assert.equal(err?.code, 'demo-demonstration:1001');
|
|
167
|
+
assert.equal(err?.status, 403);
|
|
168
|
+
assert.equal(continued, false);
|
|
169
|
+
}
|
|
170
|
+
} finally {
|
|
171
|
+
getter.mock.restore();
|
|
172
|
+
}
|
|
173
|
+
} finally {
|
|
174
|
+
await passport.signout();
|
|
175
|
+
}
|
|
176
|
+
});
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it('exempts the configured authentication and OAuth routes but still selects logout', () => {
|
|
105
180
|
for (const routePathRaw of [
|
|
106
181
|
'/home/user/passport/login',
|
|
107
182
|
'/home/user/passport/login/:module/:providerName/:clientName?',
|
|
108
|
-
'/home/user/passport/logout',
|
|
109
183
|
'/home/user/passport/register',
|
|
110
184
|
'/home/user/passport/associate/:module/:providerName/:clientName?',
|
|
111
185
|
'/home/user/passport/migrate/:module/:providerName/:clientName?',
|
|
@@ -116,6 +190,8 @@ describe('guardDemonstration.test.ts', () => {
|
|
|
116
190
|
]) {
|
|
117
191
|
assert.equal(isGuardEnabledForRoute(routePathRaw), false);
|
|
118
192
|
}
|
|
193
|
+
assert.equal(isGuardEnabledForRoute('/home/user/passport/logout'), true);
|
|
194
|
+
assert.equal(isGuardEnabledForRoute('/home/user/passport/logout', false), false);
|
|
119
195
|
assert.equal(isGuardEnabledForRoute('/test/business/write'), true);
|
|
120
196
|
});
|
|
121
197
|
});
|
|
@@ -0,0 +1,380 @@
|
|
|
1
|
+
import type { IJwtToken, IPayloadData } from 'vona-module-a-jwt';
|
|
2
|
+
import type { IAuthTokenConfig, TypeAuthTokenMode } from 'vona-module-a-user';
|
|
3
|
+
|
|
4
|
+
import assert from 'node:assert/strict';
|
|
5
|
+
import { describe, it, mock } from 'node:test';
|
|
6
|
+
import { app } from 'vona-mock';
|
|
7
|
+
import { $getAuthTokenMode, $parseAuthTokenMode } from 'vona-module-a-user';
|
|
8
|
+
|
|
9
|
+
interface ITestPayloadData extends IPayloadData {
|
|
10
|
+
token: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
interface IFixture {
|
|
14
|
+
userId: string;
|
|
15
|
+
authId: string;
|
|
16
|
+
name: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const unauthorized = (error: { code?: number }) => error.code === 401;
|
|
20
|
+
|
|
21
|
+
describe('passportSession.test.ts', { concurrency: false }, () => {
|
|
22
|
+
it('defaults to auth and rejects invalid modes, stateless sessions and reissue refresh', async () => {
|
|
23
|
+
assert.equal($parseAuthTokenMode(undefined), 'auth');
|
|
24
|
+
for (const mode of ['auth', 'session'] as const) {
|
|
25
|
+
assert.equal($parseAuthTokenMode(mode), mode);
|
|
26
|
+
}
|
|
27
|
+
for (const value of ['', 'Session', 'all', null, true, 1]) {
|
|
28
|
+
assert.throws(() => $parseAuthTokenMode(value), /auth or session/);
|
|
29
|
+
}
|
|
30
|
+
await withMode(undefined, async () => {
|
|
31
|
+
const config = app.ctx.config.modules['a-user'].authToken;
|
|
32
|
+
assert.equal($getAuthTokenMode(config), 'auth');
|
|
33
|
+
assert.equal(app.bean.passport.authTokenMode, 'auth');
|
|
34
|
+
});
|
|
35
|
+
await withMode('session', async () => {
|
|
36
|
+
const config = app.ctx.config.modules['a-user'].authToken;
|
|
37
|
+
assert.equal($getAuthTokenMode(config), 'session');
|
|
38
|
+
assert.equal(app.bean.passport.authTokenMode, 'session');
|
|
39
|
+
assert.throws(() => $getAuthTokenMode({ ...config, stateless: true }), /stateful/);
|
|
40
|
+
assert.throws(
|
|
41
|
+
() =>
|
|
42
|
+
$getAuthTokenMode({
|
|
43
|
+
...config,
|
|
44
|
+
strategy: { ...config.strategy, refreshAuthToken: 'reissue' },
|
|
45
|
+
}),
|
|
46
|
+
/reissue/,
|
|
47
|
+
);
|
|
48
|
+
});
|
|
49
|
+
await withMode(
|
|
50
|
+
'session',
|
|
51
|
+
async () => {
|
|
52
|
+
assert.throws(() => app.bean.passport.authTokenMode, /stateful/);
|
|
53
|
+
},
|
|
54
|
+
{ stateless: true },
|
|
55
|
+
);
|
|
56
|
+
await withMode(
|
|
57
|
+
'session',
|
|
58
|
+
async () => {
|
|
59
|
+
assert.throws(() => app.bean.passport.authTokenMode, /reissue/);
|
|
60
|
+
},
|
|
61
|
+
{ strategy: { signin: 'refresh', refreshAuthToken: 'reissue' } },
|
|
62
|
+
);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('auth mode keeps the shared credential token and logout revokes both logins', async () => {
|
|
66
|
+
await withFixture(async fixture => {
|
|
67
|
+
const a = await login('auth', fixture);
|
|
68
|
+
const b = await login('auth', fixture);
|
|
69
|
+
const payloadA = await verify('auth', a.accessToken);
|
|
70
|
+
const payloadB = await verify('auth', b.accessToken);
|
|
71
|
+
assert.equal(payloadA.token, payloadB.token);
|
|
72
|
+
await logout('auth', a.accessToken);
|
|
73
|
+
await rejectedPair('auth', a);
|
|
74
|
+
await rejectedPair('auth', b);
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('session logins are independent; refresh, reuse and derived tokens retain their session', async () => {
|
|
79
|
+
await withFixture(async fixture => {
|
|
80
|
+
const a = await login('session', fixture);
|
|
81
|
+
const b = await login('session', fixture);
|
|
82
|
+
const payloadA = await verify('session', a.accessToken);
|
|
83
|
+
const payloadB = await verify('session', b.accessToken);
|
|
84
|
+
assert.match(
|
|
85
|
+
payloadA.token,
|
|
86
|
+
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i,
|
|
87
|
+
);
|
|
88
|
+
assert.notEqual(payloadA.token, payloadB.token);
|
|
89
|
+
await withMode('session', async () => {
|
|
90
|
+
const cache = app.scope('a-user').cacheRedis.authToken;
|
|
91
|
+
assert.equal(
|
|
92
|
+
await cache.get(`${fixture.userId}:${fixture.authId}:${payloadA.token}`),
|
|
93
|
+
payloadA.token,
|
|
94
|
+
);
|
|
95
|
+
assert.equal(
|
|
96
|
+
await cache.get(`${fixture.userId}:${fixture.authId}:${payloadB.token}`),
|
|
97
|
+
payloadB.token,
|
|
98
|
+
);
|
|
99
|
+
});
|
|
100
|
+
let refreshed = a;
|
|
101
|
+
for (const strategy of ['refresh', 'reuse'] as const) {
|
|
102
|
+
refreshed = await withMode(
|
|
103
|
+
'session',
|
|
104
|
+
async () => {
|
|
105
|
+
const jwt = await app.bean.passport.refreshAuthToken(refreshed.refreshToken);
|
|
106
|
+
assert.equal(
|
|
107
|
+
(app.ctx.state.passportPayloadData as ITestPayloadData | undefined)?.token,
|
|
108
|
+
payloadA.token,
|
|
109
|
+
);
|
|
110
|
+
return jwt;
|
|
111
|
+
},
|
|
112
|
+
{ strategy: { signin: 'refresh', refreshAuthToken: strategy } },
|
|
113
|
+
);
|
|
114
|
+
assert.equal((await verify('session', refreshed.accessToken)).token, payloadA.token);
|
|
115
|
+
assert.equal(
|
|
116
|
+
(await verify('session', refreshed.refreshToken, 'refresh')).token,
|
|
117
|
+
payloadA.token,
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
const derived: [string, 'access' | 'oauth' | 'code'][] = [];
|
|
121
|
+
for (const create of [
|
|
122
|
+
() => app.bean.passport.createTempAuthToken(),
|
|
123
|
+
() => app.bean.passport.createOauthAuthToken(),
|
|
124
|
+
() => app.bean.passport.createOauthCode(),
|
|
125
|
+
() => app.bean.passport.createOauthCodeFromOauthAuthToken(a.accessToken),
|
|
126
|
+
]) {
|
|
127
|
+
const token = await withMode('session', async () => {
|
|
128
|
+
await app.bean.passport.checkAuthToken(a.accessToken);
|
|
129
|
+
return await create();
|
|
130
|
+
});
|
|
131
|
+
derived.push([
|
|
132
|
+
token,
|
|
133
|
+
derived.length === 1 ? 'oauth' : derived.length >= 2 ? 'code' : 'access',
|
|
134
|
+
]);
|
|
135
|
+
}
|
|
136
|
+
for (const [token, client] of derived) {
|
|
137
|
+
assert.equal((await verify('session', token, client)).token, payloadA.token);
|
|
138
|
+
}
|
|
139
|
+
const exchanged = await withMode('session', () =>
|
|
140
|
+
app.bean.passport.createAuthTokenFromOauthCode(derived[2][0]),
|
|
141
|
+
);
|
|
142
|
+
assert.equal((await verify('session', exchanged.accessToken)).token, payloadA.token);
|
|
143
|
+
await logout('session', refreshed.accessToken);
|
|
144
|
+
await rejectedPair('session', a);
|
|
145
|
+
await rejectedPair('session', refreshed);
|
|
146
|
+
await rejectedPair('session', exchanged);
|
|
147
|
+
for (const [token, client] of derived) {
|
|
148
|
+
await withMode('session', () =>
|
|
149
|
+
assert.rejects(() => app.bean.passport.checkAuthToken(token, client), unauthorized),
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
await withMode('session', () =>
|
|
153
|
+
assert.rejects(
|
|
154
|
+
() => app.bean.passport.createAuthTokenFromOauthCode(derived[2][0]),
|
|
155
|
+
unauthorized,
|
|
156
|
+
),
|
|
157
|
+
);
|
|
158
|
+
await withMode('session', () =>
|
|
159
|
+
assert.rejects(
|
|
160
|
+
() => app.bean.passport.createOauthCodeFromOauthAuthToken(a.accessToken),
|
|
161
|
+
unauthorized,
|
|
162
|
+
),
|
|
163
|
+
);
|
|
164
|
+
await verify('session', b.accessToken);
|
|
165
|
+
await verify('session', b.refreshToken, 'refresh');
|
|
166
|
+
});
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it('logout at the refresh boundary cannot resurrect a deleted session', async () => {
|
|
170
|
+
await withFixture(async fixture => {
|
|
171
|
+
const a = await login('session', fixture);
|
|
172
|
+
const b = await login('session', fixture);
|
|
173
|
+
const refreshed = await withMode('session', async () => {
|
|
174
|
+
const redisToken = app.scope('a-user').service.redisToken;
|
|
175
|
+
const refresh = redisToken.refresh.bind(redisToken);
|
|
176
|
+
const boundary = mock.method(
|
|
177
|
+
redisToken,
|
|
178
|
+
'refresh',
|
|
179
|
+
async (payload: IPayloadData, ttl?: number) => {
|
|
180
|
+
// A separate request deletes the record after verification, before TTL extension.
|
|
181
|
+
await logout('session', a.accessToken);
|
|
182
|
+
await refresh(payload, ttl);
|
|
183
|
+
},
|
|
184
|
+
);
|
|
185
|
+
try {
|
|
186
|
+
const jwt = await app.bean.passport.refreshAuthToken(a.refreshToken);
|
|
187
|
+
assert.equal(boundary.mock.callCount(), 1);
|
|
188
|
+
return jwt;
|
|
189
|
+
} finally {
|
|
190
|
+
boundary.mock.restore();
|
|
191
|
+
}
|
|
192
|
+
});
|
|
193
|
+
await rejectedPair('session', a);
|
|
194
|
+
await rejectedPair('session', refreshed);
|
|
195
|
+
await verify('session', b.accessToken);
|
|
196
|
+
await verify('session', b.refreshToken, 'refresh');
|
|
197
|
+
});
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
it('requires verified payload state for session signout and rejects effective stateless overrides', async () => {
|
|
201
|
+
await withFixture(async fixture => {
|
|
202
|
+
const jwt = await login('session', fixture);
|
|
203
|
+
await withMode('session', async () => {
|
|
204
|
+
const verified = await app.bean.passport.checkAuthToken(jwt.accessToken);
|
|
205
|
+
assert.ok(verified);
|
|
206
|
+
assert.equal(app.ctx.state.passportPayloadData, verified.payloadData);
|
|
207
|
+
await app.bean.passport.setCurrent(verified.passport);
|
|
208
|
+
assert.equal(app.ctx.state.passportPayloadData, undefined);
|
|
209
|
+
await assert.rejects(() => app.bean.passport.signout(), unauthorized);
|
|
210
|
+
});
|
|
211
|
+
await verify('session', jwt.accessToken);
|
|
212
|
+
await withMode('session', async () => {
|
|
213
|
+
const verified = await app.bean.passport.checkAuthToken(jwt.accessToken);
|
|
214
|
+
assert.ok(verified);
|
|
215
|
+
await assert.rejects(
|
|
216
|
+
() => app.bean.passport.signin(verified.passport, { authToken: { stateless: true } }),
|
|
217
|
+
/stateful/,
|
|
218
|
+
);
|
|
219
|
+
});
|
|
220
|
+
await withMode('session', () =>
|
|
221
|
+
assert.rejects(
|
|
222
|
+
() =>
|
|
223
|
+
app.bean.auth.authenticate('auth-simple:simple', {
|
|
224
|
+
clientName: 'default',
|
|
225
|
+
state: { intention: 'login' },
|
|
226
|
+
clientOptions: {
|
|
227
|
+
username: fixture.name,
|
|
228
|
+
password: 'initial-password',
|
|
229
|
+
authTokenOptions: { stateless: true },
|
|
230
|
+
},
|
|
231
|
+
}),
|
|
232
|
+
/stateful/,
|
|
233
|
+
),
|
|
234
|
+
);
|
|
235
|
+
});
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
it('kickOut removes all user token shapes independently of mode without prefix collisions', async () => {
|
|
239
|
+
await withFixture(async fixture => {
|
|
240
|
+
const a = await login('session', fixture);
|
|
241
|
+
const b = await login('session', fixture);
|
|
242
|
+
const auth = await login('auth', fixture);
|
|
243
|
+
const keys = [
|
|
244
|
+
`${fixture.userId}:owned-auth`,
|
|
245
|
+
`${fixture.userId}:owned-auth:owned-session`,
|
|
246
|
+
`${fixture.userId}0:owned-auth:neighbor`,
|
|
247
|
+
];
|
|
248
|
+
try {
|
|
249
|
+
await withMode('session', async () => {
|
|
250
|
+
const cache = app.scope('a-user').cacheRedis.authToken;
|
|
251
|
+
for (const key of keys) await cache.set('owned-token', key);
|
|
252
|
+
});
|
|
253
|
+
await withMode('auth', async () => {
|
|
254
|
+
const user = await app.bean.user.findOneById(fixture.userId);
|
|
255
|
+
assert.ok(user);
|
|
256
|
+
await app.bean.passport.kickOut(user);
|
|
257
|
+
});
|
|
258
|
+
await rejectedPair('session', a);
|
|
259
|
+
await rejectedPair('session', b);
|
|
260
|
+
await rejectedPair('auth', auth);
|
|
261
|
+
await withMode('session', async () => {
|
|
262
|
+
const cache = app.scope('a-user').cacheRedis.authToken;
|
|
263
|
+
assert.equal(await cache.get(keys[0]), undefined);
|
|
264
|
+
assert.equal(await cache.get(keys[1]), undefined);
|
|
265
|
+
assert.equal(await cache.get(keys[2]), 'owned-token');
|
|
266
|
+
});
|
|
267
|
+
} finally {
|
|
268
|
+
await withMode('session', async () => {
|
|
269
|
+
for (const key of keys) await app.scope('a-user').cacheRedis.authToken.del(key);
|
|
270
|
+
});
|
|
271
|
+
}
|
|
272
|
+
});
|
|
273
|
+
});
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
async function withMode<T>(
|
|
277
|
+
mode: TypeAuthTokenMode | undefined,
|
|
278
|
+
callback: () => Promise<T>,
|
|
279
|
+
overrides: Partial<IAuthTokenConfig> = {},
|
|
280
|
+
): Promise<T> {
|
|
281
|
+
return await app.bean.executor.mockCtx(async () => {
|
|
282
|
+
const config = app.ctx.config;
|
|
283
|
+
Object.defineProperty(app.ctx, 'config', {
|
|
284
|
+
configurable: true,
|
|
285
|
+
value: {
|
|
286
|
+
...config,
|
|
287
|
+
modules: {
|
|
288
|
+
...config.modules,
|
|
289
|
+
'a-user': {
|
|
290
|
+
...config.modules['a-user'],
|
|
291
|
+
authToken: { ...config.modules['a-user'].authToken, mode, ...overrides },
|
|
292
|
+
},
|
|
293
|
+
},
|
|
294
|
+
},
|
|
295
|
+
});
|
|
296
|
+
return await callback();
|
|
297
|
+
});
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
async function login(mode: TypeAuthTokenMode, fixture: IFixture): Promise<IJwtToken> {
|
|
301
|
+
return await withMode(mode, async () => {
|
|
302
|
+
const jwt = await app.bean.auth.authenticate('auth-simple:simple', {
|
|
303
|
+
clientName: 'default',
|
|
304
|
+
state: { intention: 'login' },
|
|
305
|
+
clientOptions: { username: fixture.name, password: 'initial-password' },
|
|
306
|
+
});
|
|
307
|
+
assert.ok(jwt);
|
|
308
|
+
assert.ok((app.ctx.state.passportPayloadData as ITestPayloadData | undefined)?.token);
|
|
309
|
+
return jwt;
|
|
310
|
+
});
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
async function verify(
|
|
314
|
+
mode: TypeAuthTokenMode,
|
|
315
|
+
token: string,
|
|
316
|
+
client: 'access' | 'refresh' | 'oauth' | 'code' = 'access',
|
|
317
|
+
): Promise<ITestPayloadData> {
|
|
318
|
+
return await withMode(mode, async () => {
|
|
319
|
+
const verified = await app.bean.passport.checkAuthToken(token, client);
|
|
320
|
+
assert.ok(verified);
|
|
321
|
+
assert.equal(app.ctx.state.passportPayloadData, verified.payloadData);
|
|
322
|
+
return verified.payloadData as ITestPayloadData;
|
|
323
|
+
});
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
async function rejectedPair(mode: TypeAuthTokenMode, jwt: IJwtToken) {
|
|
327
|
+
await withMode(mode, () =>
|
|
328
|
+
assert.rejects(() => app.bean.passport.checkAuthToken(jwt.accessToken), unauthorized),
|
|
329
|
+
);
|
|
330
|
+
await withMode(mode, () =>
|
|
331
|
+
assert.rejects(() => app.bean.passport.refreshAuthToken(jwt.refreshToken), unauthorized),
|
|
332
|
+
);
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
async function logout(mode: TypeAuthTokenMode, accessToken: string) {
|
|
336
|
+
await withMode(mode, async () => {
|
|
337
|
+
await app.bean.passport.checkAuthToken(accessToken);
|
|
338
|
+
await app.bean.passport.signout();
|
|
339
|
+
assert.equal(app.ctx.state.passportPayloadData, undefined);
|
|
340
|
+
assert.equal(app.bean.passport.current, undefined);
|
|
341
|
+
});
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
async function withFixture(callback: (fixture: IFixture) => Promise<void>) {
|
|
345
|
+
let userId: string | undefined;
|
|
346
|
+
let authSimpleId: string | undefined;
|
|
347
|
+
try {
|
|
348
|
+
const fixture = await app.bean.executor.mockCtx(async () => {
|
|
349
|
+
const name = `passport-session-${crypto.randomUUID()}`;
|
|
350
|
+
const user = await app.bean.user.register({ name }, true);
|
|
351
|
+
userId = user.id.toString();
|
|
352
|
+
const authSimple = await app
|
|
353
|
+
.scope('auth-simple')
|
|
354
|
+
.service.authSimple.createForUser(user.id, 'initial-password');
|
|
355
|
+
assert.ok(authSimple);
|
|
356
|
+
authSimpleId = authSimple.id.toString();
|
|
357
|
+
const provider = await app.bean.authProvider.get({
|
|
358
|
+
providerName: 'auth-simple:simple',
|
|
359
|
+
clientName: 'default',
|
|
360
|
+
});
|
|
361
|
+
const auth = await app
|
|
362
|
+
.scope('a-auth')
|
|
363
|
+
.model.auth.get({ userId: user.id, authProviderId: provider.id });
|
|
364
|
+
assert.ok(auth);
|
|
365
|
+
return { userId, authId: auth.id.toString(), name };
|
|
366
|
+
});
|
|
367
|
+
await callback(fixture);
|
|
368
|
+
} finally {
|
|
369
|
+
if (userId) {
|
|
370
|
+
await app.bean.executor.mockCtx(async () => {
|
|
371
|
+
const user = await app.bean.user.findOneById(userId!);
|
|
372
|
+
if (user) await app.bean.passport.kickOut(user);
|
|
373
|
+
await app.scope('a-auth').model.auth.delete({ userId });
|
|
374
|
+
if (authSimpleId) await app.scope('auth-simple').model.authSimple.deleteById(authSimpleId);
|
|
375
|
+
await app.scope('home-user').model.roleUser.delete({ userId });
|
|
376
|
+
await app.bean.user.removeById(userId!);
|
|
377
|
+
});
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "vona-suite-a-cabloy",
|
|
3
|
-
"version": "5.1.
|
|
3
|
+
"version": "5.1.71",
|
|
4
4
|
"gitHead": "a79189b882c17af5911573896a781bbb0046d37d",
|
|
5
5
|
"description": "",
|
|
6
6
|
"author": "",
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
"vona-module-a-cabloy": "workspace:^",
|
|
11
11
|
"vona-module-a-layoutprofile": "^5.0.2",
|
|
12
12
|
"vona-module-a-markdown": "^5.0.3",
|
|
13
|
-
"vona-module-a-rbac": "^5.0.
|
|
13
|
+
"vona-module-a-rbac": "^5.0.19",
|
|
14
14
|
"vona-module-a-socket": "workspace:^",
|
|
15
15
|
"vona-module-a-ssr": "workspace:^",
|
|
16
16
|
"vona-module-a-ssrhmr": "workspace:^"
|
|
@@ -11,7 +11,7 @@ function tool(options: IMcpToolOptions): MethodDecorator;
|
|
|
11
11
|
function tool(options: IMcpToolOptions = {}): ClassDecorator & MethodDecorator {
|
|
12
12
|
return function (target: object, prop?: MetadataKey, descriptor?: PropertyDescriptor) {
|
|
13
13
|
if (prop === undefined && options.name !== undefined) {
|
|
14
|
-
throw new Error('
|
|
14
|
+
throw new Error('Mcp.tool name is only valid on a controller action');
|
|
15
15
|
}
|
|
16
16
|
// An options object, including an empty one, is the explicit opt-in marker.
|
|
17
17
|
const metadata = appMetadata.getOwnMetadataMap(
|
|
@@ -25,4 +25,4 @@ function tool(options: IMcpToolOptions = {}): ClassDecorator & MethodDecorator {
|
|
|
25
25
|
} as ClassDecorator & MethodDecorator;
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
-
export const
|
|
28
|
+
export const Mcp = { tool };
|