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.
Files changed (39) hide show
  1. package/.cabloy-version +1 -1
  2. package/.github/workflows/vona-test-sqlite3.yml +1 -1
  3. package/CHANGELOG.md +20 -0
  4. package/package.json +1 -1
  5. package/repo-agent-governance/managed-assets.json +6 -6
  6. package/repo-agent-governance/policies/repository.md +1 -0
  7. package/repo-agent-governance/skills/cabloy-frontend-scaffold/SKILL.md +4 -2
  8. package/repo-docs/backend/auth-guide.md +48 -0
  9. package/repo-docs/backend/demonstration-mode-guide.md +22 -5
  10. package/repo-docs/backend/openapi-guide.md +1 -1
  11. package/repo-docs/frontend/behavior-guide.md +12 -0
  12. package/repo-e2e/specs/home-user-account.spec.ts +100 -5
  13. package/vona/env/.env +2 -0
  14. package/vona/packages-cli/cli/package.json +1 -1
  15. package/vona/packages-cli/cli-set-api/package.json +1 -1
  16. package/vona/packages-utils/babel-plugin-vona-bean-module/package.json +1 -1
  17. package/vona/packages-utils/babel-plugin-vona-bean-module/src/index.test.ts +27 -0
  18. package/vona/packages-utils/babel-plugin-vona-bean-module/src/index.ts +1 -0
  19. package/vona/packages-vona/vona/package.json +1 -1
  20. package/vona/src/suite/a-demo/modules/demo-demonstration/src/bean/guard.demonstration.ts +8 -1
  21. package/vona/src/suite/a-demo/modules/demo-demonstration/test/guardDemonstration.test.ts +76 -1
  22. package/vona/src/suite/a-home/modules/home-user/test/passportSession.test.ts +380 -0
  23. package/vona/src/suite-vendor/a-cabloy/modules/a-rbac/package.json +1 -1
  24. package/vona/src/suite-vendor/a-cabloy/package.json +2 -2
  25. package/vona/src/suite-vendor/a-vona/modules/a-openapiutils/package.json +1 -1
  26. package/vona/src/suite-vendor/a-vona/modules/a-openapiutils/src/lib/decorator/mcp.ts +2 -2
  27. package/vona/src/suite-vendor/a-vona/modules/a-permission/package.json +1 -1
  28. package/vona/src/suite-vendor/a-vona/modules/a-user/package.json +1 -1
  29. package/vona/src/suite-vendor/a-vona/modules/a-user/src/bean/bean.passport.ts +82 -20
  30. package/vona/src/suite-vendor/a-vona/modules/a-user/src/config/config.ts +4 -1
  31. package/vona/src/suite-vendor/a-vona/modules/a-user/src/lib/auth.ts +20 -1
  32. package/vona/src/suite-vendor/a-vona/modules/a-user/src/main.ts +3 -0
  33. package/vona/src/suite-vendor/a-vona/modules/a-user/src/service/authTokenAdapter.ts +4 -1
  34. package/vona/src/suite-vendor/a-vona/modules/a-user/src/service/redisToken.ts +6 -2
  35. package/vona/src/suite-vendor/a-vona/modules/a-user/src/types/auth.ts +8 -0
  36. package/vona/src/suite-vendor/a-vona/modules/a-user/src/types/passport.ts +1 -0
  37. package/vona/src/suite-vendor/a-vona/package.json +1 -1
  38. package/zova/src/suite/a-home/modules/home-layoutadmin/src/component/layoutAdmin/render.user.tsx +5 -4
  39. 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-module-a-rbac",
3
- "version": "5.0.18",
3
+ "version": "5.0.19",
4
4
  "description": "",
5
5
  "keywords": [
6
6
  "Vona Module"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vona-suite-a-cabloy",
3
- "version": "5.1.70",
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.18",
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:^"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vona-module-a-openapiutils",
3
- "version": "5.1.23",
3
+ "version": "5.1.24",
4
4
  "gitHead": "a79189b882c17af5911573896a781bbb0046d37d",
5
5
  "description": "",
6
6
  "keywords": [
@@ -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('mcp.tool name is only valid on a controller action');
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 mcp = { tool };
28
+ export const Mcp = { tool };
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vona-module-a-permission",
3
- "version": "5.1.35",
3
+ "version": "5.1.36",
4
4
  "gitHead": "a79189b882c17af5911573896a781bbb0046d37d",
5
5
  "description": "",
6
6
  "keywords": [
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vona-module-a-user",
3
- "version": "5.1.26",
3
+ "version": "5.1.27",
4
4
  "gitHead": "a79189b882c17af5911573896a781bbb0046d37d",
5
5
  "description": "",
6
6
  "keywords": [
@@ -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 { IAuth, IAuthIdRecord, IAuthTokenOptions, ISigninOptions } from '../types/auth.ts';
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
- return await this.bean.jwt.create(payloadData, { dev: passport.auth?.id.toString() === '-1' });
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 = await this.passportAdapter.serialize(passport);
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
- return await this.bean.jwt.create(payloadData);
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 = await this.bean.jwt.get('access').verify(accessToken);
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 async _passportSerialize(passport: IPassport, authTokenOptions?: IAuthTokenOptions) {
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
- if (!authProviderId) return authTokenOptionsDefault;
324
- const { clientOptions } = await this.bean.authProvider.getClientOptions(
325
- {
326
- id: authProviderId,
327
- },
328
- { authTokenOptions: authTokenOptionsCustom ?? {} },
329
- {
330
- authTokenOptions: authTokenOptionsDefault,
331
- },
332
- );
333
- if (!clientOptions) this.app.throw(403);
334
- return clientOptions.authTokenOptions!;
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
- export function config(_app: VonaApplication) {
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],