cabloy 5.1.243 → 5.1.245
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 +20 -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 +22 -5
- 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 +100 -5
- 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-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/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 +76 -1
- 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-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
|
@@ -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 };
|
|
@@ -10,13 +10,19 @@ import { catchError, isNil } from '@cabloy/utils';
|
|
|
10
10
|
import { BeanBase, beanFullNameFromOnionName } from 'vona';
|
|
11
11
|
import { Bean } from 'vona-module-a-bean';
|
|
12
12
|
|
|
13
|
-
import type {
|
|
13
|
+
import type {
|
|
14
|
+
IAuth,
|
|
15
|
+
IAuthIdRecord,
|
|
16
|
+
IAuthTokenOptions,
|
|
17
|
+
ISigninOptions,
|
|
18
|
+
TypeAuthTokenMode,
|
|
19
|
+
} from '../types/auth.ts';
|
|
14
20
|
import type { IAuthTokenAdapter } from '../types/authToken.ts';
|
|
15
21
|
import type { IPassport, IPassportAdapter } from '../types/passport.ts';
|
|
16
22
|
import type { IRole, IRoleNameRecord } from '../types/role.ts';
|
|
17
23
|
import type { IUser, IUserNameRecord } from '../types/user.ts';
|
|
18
24
|
|
|
19
|
-
import { $getAuthIdSystem } from '../lib/auth.ts';
|
|
25
|
+
import { $getAuthIdSystem, $getAuthTokenMode } from '../lib/auth.ts';
|
|
20
26
|
|
|
21
27
|
@Bean()
|
|
22
28
|
export class BeanPassport extends BeanBase {
|
|
@@ -43,6 +49,10 @@ export class BeanPassport extends BeanBase {
|
|
|
43
49
|
return this._passportAdapter;
|
|
44
50
|
}
|
|
45
51
|
|
|
52
|
+
public get authTokenMode(): TypeAuthTokenMode {
|
|
53
|
+
return $getAuthTokenMode(this.scope.config.authToken);
|
|
54
|
+
}
|
|
55
|
+
|
|
46
56
|
public get isAuthenticated(): boolean {
|
|
47
57
|
const user = this.currentUser;
|
|
48
58
|
return !!user && !user.anonymous;
|
|
@@ -71,6 +81,7 @@ export class BeanPassport extends BeanBase {
|
|
|
71
81
|
}
|
|
72
82
|
|
|
73
83
|
public async setCurrent(passport: IPassport | undefined) {
|
|
84
|
+
this.ctx.state.passportPayloadData = undefined;
|
|
74
85
|
this.ctx.state.passport = await this.passportAdapter.setCurrent(passport);
|
|
75
86
|
}
|
|
76
87
|
|
|
@@ -107,9 +118,13 @@ export class BeanPassport extends BeanBase {
|
|
|
107
118
|
ttl: this.scope.config.authToken.ttl,
|
|
108
119
|
},
|
|
109
120
|
);
|
|
110
|
-
const payloadData = await this._passportSerialize(passport, authTokenOptions);
|
|
121
|
+
const payloadData = await this._passportSerialize(passport, authTokenOptions, true);
|
|
111
122
|
// jwt token
|
|
112
|
-
|
|
123
|
+
const jwt = await this.bean.jwt.create(payloadData, {
|
|
124
|
+
dev: passport.auth?.id.toString() === '-1',
|
|
125
|
+
});
|
|
126
|
+
this.ctx.state.passportPayloadData = payloadData;
|
|
127
|
+
return jwt;
|
|
113
128
|
}
|
|
114
129
|
|
|
115
130
|
public async signout(): Promise<void> {
|
|
@@ -117,7 +132,10 @@ export class BeanPassport extends BeanBase {
|
|
|
117
132
|
const passport = this.current;
|
|
118
133
|
if (!passport) return;
|
|
119
134
|
// removeAuthToken
|
|
120
|
-
const payloadData =
|
|
135
|
+
const payloadData =
|
|
136
|
+
this.authTokenMode === 'session'
|
|
137
|
+
? this._getCurrentPayloadData()
|
|
138
|
+
: await this.passportAdapter.serialize(passport);
|
|
121
139
|
await this.authTokenAdapter.remove(payloadData);
|
|
122
140
|
// event
|
|
123
141
|
await this.scope.event.signout.emit(passport);
|
|
@@ -191,6 +209,7 @@ export class BeanPassport extends BeanBase {
|
|
|
191
209
|
}
|
|
192
210
|
// setCurrent
|
|
193
211
|
await this.setCurrent(passport);
|
|
212
|
+
this.ctx.state.passportPayloadData = payloadData;
|
|
194
213
|
return { payloadData, passport };
|
|
195
214
|
}
|
|
196
215
|
|
|
@@ -211,7 +230,9 @@ export class BeanPassport extends BeanBase {
|
|
|
211
230
|
);
|
|
212
231
|
payloadData = await this._handlePayloadData(payloadData, authTokenOptions);
|
|
213
232
|
// jwt token
|
|
214
|
-
|
|
233
|
+
const jwt = await this.bean.jwt.create(payloadData);
|
|
234
|
+
this.ctx.state.passportPayloadData = payloadData;
|
|
235
|
+
return jwt;
|
|
215
236
|
}
|
|
216
237
|
|
|
217
238
|
// only created by accessToken
|
|
@@ -247,7 +268,10 @@ export class BeanPassport extends BeanBase {
|
|
|
247
268
|
|
|
248
269
|
public async createOauthCodeFromOauthAuthToken(accessToken: string, options?: IJwtSignOptions) {
|
|
249
270
|
// payloadData
|
|
250
|
-
const payloadData =
|
|
271
|
+
const payloadData =
|
|
272
|
+
this.authTokenMode === 'session'
|
|
273
|
+
? (await this.checkAuthToken(accessToken))?.payloadData
|
|
274
|
+
: await this.bean.jwt.get('access').verify(accessToken);
|
|
251
275
|
if (!payloadData) return this.app.throw(401);
|
|
252
276
|
// code
|
|
253
277
|
return await this.bean.jwt.createOauthCode(payloadData, options);
|
|
@@ -282,7 +306,24 @@ export class BeanPassport extends BeanBase {
|
|
|
282
306
|
return true;
|
|
283
307
|
}
|
|
284
308
|
|
|
285
|
-
private
|
|
309
|
+
private _getCurrentPayloadData(): IPayloadData {
|
|
310
|
+
const payloadData = this.ctx.state.passportPayloadData;
|
|
311
|
+
if (!payloadData?.[this.scope.config.payloadData.fields.token]) return this.app.throw(401);
|
|
312
|
+
return payloadData;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
private async _passportSerialize(
|
|
316
|
+
passport: IPassport,
|
|
317
|
+
authTokenOptions?: IAuthTokenOptions,
|
|
318
|
+
signin = false,
|
|
319
|
+
) {
|
|
320
|
+
if (this.authTokenMode === 'session') {
|
|
321
|
+
if (signin) {
|
|
322
|
+
const payloadData = await this.passportAdapter.serialize(passport);
|
|
323
|
+
return await this.authTokenAdapter.create(payloadData, authTokenOptions?.ttl);
|
|
324
|
+
}
|
|
325
|
+
return await this._handlePayloadData(this._getCurrentPayloadData(), authTokenOptions);
|
|
326
|
+
}
|
|
286
327
|
// serialize
|
|
287
328
|
const payloadData = await this.passportAdapter.serialize(passport);
|
|
288
329
|
return await this._handlePayloadData(payloadData, authTokenOptions);
|
|
@@ -296,6 +337,18 @@ export class BeanPassport extends BeanBase {
|
|
|
296
337
|
const stateless = authTokenOptions?.stateless;
|
|
297
338
|
const strategy = authTokenOptions?.strategy ?? 'refresh';
|
|
298
339
|
const ttl = authTokenOptions?.ttl;
|
|
340
|
+
if (this.authTokenMode === 'session') {
|
|
341
|
+
if (stateless) throw new Error('Session auth-token mode requires stateful authentication');
|
|
342
|
+
if (strategy === 'reissue') {
|
|
343
|
+
throw new Error('Session auth-token mode does not support the reissue refresh strategy');
|
|
344
|
+
}
|
|
345
|
+
const verified = await this.authTokenAdapter.verify(payloadData);
|
|
346
|
+
if (!verified) return this.app.throw(401);
|
|
347
|
+
if (strategy === 'refresh') {
|
|
348
|
+
await this.authTokenAdapter.refresh(payloadData, ttl);
|
|
349
|
+
}
|
|
350
|
+
return payloadData;
|
|
351
|
+
}
|
|
299
352
|
if (stateless) {
|
|
300
353
|
// do nothing
|
|
301
354
|
return payloadData;
|
|
@@ -320,17 +373,26 @@ export class BeanPassport extends BeanBase {
|
|
|
320
373
|
authTokenOptionsCustom: IAuthTokenOptions | undefined,
|
|
321
374
|
authTokenOptionsDefault: IAuthTokenOptions,
|
|
322
375
|
) {
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
{
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
{
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
376
|
+
let authTokenOptions = authTokenOptionsDefault;
|
|
377
|
+
if (this.authTokenMode === 'session') {
|
|
378
|
+
authTokenOptions = { ...authTokenOptions, ...authTokenOptionsCustom };
|
|
379
|
+
}
|
|
380
|
+
if (authProviderId) {
|
|
381
|
+
const { clientOptions } = await this.bean.authProvider.getClientOptions(
|
|
382
|
+
{
|
|
383
|
+
id: authProviderId,
|
|
384
|
+
},
|
|
385
|
+
{ authTokenOptions: authTokenOptionsCustom ?? {} },
|
|
386
|
+
{
|
|
387
|
+
authTokenOptions: authTokenOptionsDefault,
|
|
388
|
+
},
|
|
389
|
+
);
|
|
390
|
+
if (!clientOptions) this.app.throw(403);
|
|
391
|
+
authTokenOptions = clientOptions.authTokenOptions!;
|
|
392
|
+
}
|
|
393
|
+
if (this.authTokenMode === 'session' && authTokenOptions.stateless) {
|
|
394
|
+
throw new Error('Session auth-token mode requires stateful authentication');
|
|
395
|
+
}
|
|
396
|
+
return authTokenOptions;
|
|
335
397
|
}
|
|
336
398
|
}
|
|
@@ -2,12 +2,15 @@ import type { VonaApplication } from 'vona';
|
|
|
2
2
|
|
|
3
3
|
import type { ConfigUser } from '../types/auth.ts';
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
import { $parseAuthTokenMode } from '../lib/auth.ts';
|
|
6
|
+
|
|
7
|
+
export function config(app: VonaApplication) {
|
|
6
8
|
const configUser: ConfigUser = {
|
|
7
9
|
user: {
|
|
8
10
|
autoActivate: false,
|
|
9
11
|
},
|
|
10
12
|
authToken: {
|
|
13
|
+
mode: $parseAuthTokenMode(app.meta.env.AUTH_TOKEN_MODE),
|
|
11
14
|
stateless: false,
|
|
12
15
|
strategy: {
|
|
13
16
|
refreshAuthToken: 'refresh',
|
|
@@ -1,6 +1,25 @@
|
|
|
1
1
|
import type { TableIdentity } from 'table-identity';
|
|
2
2
|
|
|
3
|
-
import type { IAuthIdRecord } from '../types/auth.ts';
|
|
3
|
+
import type { IAuthIdRecord, IAuthTokenConfig, TypeAuthTokenMode } from '../types/auth.ts';
|
|
4
|
+
|
|
5
|
+
export function $parseAuthTokenMode(value: unknown): TypeAuthTokenMode {
|
|
6
|
+
if (value === undefined) return 'auth';
|
|
7
|
+
if (value === 'auth' || value === 'session') return value;
|
|
8
|
+
throw new Error('AUTH_TOKEN_MODE / authToken.mode must be auth or session');
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function $getAuthTokenMode(config: IAuthTokenConfig): TypeAuthTokenMode {
|
|
12
|
+
const mode = $parseAuthTokenMode(config.mode);
|
|
13
|
+
if (mode === 'session') {
|
|
14
|
+
if (config.stateless) {
|
|
15
|
+
throw new Error('Session auth-token mode requires stateful authentication');
|
|
16
|
+
}
|
|
17
|
+
if (config.strategy.refreshAuthToken === 'reissue') {
|
|
18
|
+
throw new Error('Session auth-token mode does not support the reissue refresh strategy');
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
return mode;
|
|
22
|
+
}
|
|
4
23
|
|
|
5
24
|
export function $getAuthIdSystem<K extends keyof IAuthIdRecord>(
|
|
6
25
|
_authName: IAuthIdRecord[K],
|