nestjs-ddd-cli 3.2.2 → 3.3.0
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/README.md +118 -9
- package/ddd.schema.json +11 -1
- package/dist/commands/doctor-enhanced.d.ts +1 -0
- package/dist/commands/doctor-enhanced.js +94 -79
- package/dist/commands/doctor-enhanced.js.map +1 -1
- package/dist/commands/doctor.d.ts +9 -0
- package/dist/commands/doctor.js +68 -21
- package/dist/commands/doctor.js.map +1 -1
- package/dist/commands/generate-all.js +54 -28
- package/dist/commands/generate-all.js.map +1 -1
- package/dist/commands/generate-controller.d.ts +1 -0
- package/dist/commands/generate-controller.js +71 -0
- package/dist/commands/generate-controller.js.map +1 -0
- package/dist/commands/generate-domain-service.d.ts +1 -1
- package/dist/commands/generate-domain-service.js +6 -4
- package/dist/commands/generate-domain-service.js.map +1 -1
- package/dist/commands/generate-dto.d.ts +4 -0
- package/dist/commands/generate-dto.js +162 -0
- package/dist/commands/generate-dto.js.map +1 -0
- package/dist/commands/generate-entity.js +7 -2
- package/dist/commands/generate-entity.js.map +1 -1
- package/dist/commands/generate-event.js +10 -4
- package/dist/commands/generate-event.js.map +1 -1
- package/dist/commands/generate-migration-deployment.d.ts +15 -0
- package/dist/commands/generate-migration-deployment.js +326 -0
- package/dist/commands/generate-migration-deployment.js.map +1 -0
- package/dist/commands/generate-migration-deployment.spec.d.ts +1 -0
- package/dist/commands/generate-migration-deployment.spec.js +337 -0
- package/dist/commands/generate-migration-deployment.spec.js.map +1 -0
- package/dist/commands/generate-module.js +4 -2
- package/dist/commands/generate-module.js.map +1 -1
- package/dist/commands/generate-query.js +23 -4
- package/dist/commands/generate-query.js.map +1 -1
- package/dist/commands/generate-repository.d.ts +1 -1
- package/dist/commands/generate-repository.js +17 -34
- package/dist/commands/generate-repository.js.map +1 -1
- package/dist/commands/generate-service.js +19 -4
- package/dist/commands/generate-service.js.map +1 -1
- package/dist/commands/generate-usecase.js +12 -31
- package/dist/commands/generate-usecase.js.map +1 -1
- package/dist/commands/generate-value-object.js +3 -14
- package/dist/commands/generate-value-object.js.map +1 -1
- package/dist/commands/init-project.d.ts +3 -0
- package/dist/commands/init-project.js +19 -9
- package/dist/commands/init-project.js.map +1 -1
- package/dist/commands/interactive-scaffold.js +10 -0
- package/dist/commands/interactive-scaffold.js.map +1 -1
- package/dist/commands/migration.d.ts +3 -0
- package/dist/commands/migration.js +83 -48
- package/dist/commands/migration.js.map +1 -1
- package/dist/commands/recipe.d.ts +2 -0
- package/dist/commands/recipe.js +229 -85
- package/dist/commands/recipe.js.map +1 -1
- package/dist/commands/recipes/banklink-connector-contract.recipe.d.ts +1 -0
- package/dist/commands/recipes/banklink-connector-contract.recipe.js +161 -0
- package/dist/commands/recipes/banklink-connector-contract.recipe.js.map +1 -0
- package/dist/commands/recipes/bounded-json-http.recipe.d.ts +2 -0
- package/dist/commands/recipes/bounded-json-http.recipe.js +53 -0
- package/dist/commands/recipes/bounded-json-http.recipe.js.map +1 -0
- package/dist/commands/recipes/business-reference-identifiers.recipe.d.ts +1 -0
- package/dist/commands/recipes/business-reference-identifiers.recipe.js +223 -0
- package/dist/commands/recipes/business-reference-identifiers.recipe.js.map +1 -0
- package/dist/commands/recipes/event-backbone.recipe.d.ts +1 -0
- package/dist/commands/recipes/event-backbone.recipe.js +1118 -0
- package/dist/commands/recipes/event-backbone.recipe.js.map +1 -0
- package/dist/commands/recipes/external-projection-worker.recipe.d.ts +6 -0
- package/dist/commands/recipes/external-projection-worker.recipe.js +211 -0
- package/dist/commands/recipes/external-projection-worker.recipe.js.map +1 -0
- package/dist/commands/recipes/index.d.ts +11 -0
- package/dist/commands/recipes/index.js +11 -0
- package/dist/commands/recipes/index.js.map +1 -1
- package/dist/commands/recipes/jest-cjs-compat.recipe.d.ts +3 -0
- package/dist/commands/recipes/jest-cjs-compat.recipe.js +130 -0
- package/dist/commands/recipes/jest-cjs-compat.recipe.js.map +1 -0
- package/dist/commands/recipes/joi-env.recipe.js +1 -1
- package/dist/commands/recipes/kafka-consumer.recipe.d.ts +1 -0
- package/dist/commands/recipes/kafka-consumer.recipe.js +65 -0
- package/dist/commands/recipes/kafka-consumer.recipe.js.map +1 -0
- package/dist/commands/recipes/oauth2-client-credentials.recipe.d.ts +1 -0
- package/dist/commands/recipes/oauth2-client-credentials.recipe.js +60 -0
- package/dist/commands/recipes/oauth2-client-credentials.recipe.js.map +1 -0
- package/dist/commands/recipes/oauth2-token-introspection.recipe.d.ts +1 -0
- package/dist/commands/recipes/oauth2-token-introspection.recipe.js +67 -0
- package/dist/commands/recipes/oauth2-token-introspection.recipe.js.map +1 -0
- package/dist/commands/recipes/oidc-bff-session.recipe.d.ts +1 -0
- package/dist/commands/recipes/oidc-bff-session.recipe.js +57 -0
- package/dist/commands/recipes/oidc-bff-session.recipe.js.map +1 -0
- package/dist/commands/recipes/oidc-dashboard.recipe.d.ts +1 -0
- package/dist/commands/recipes/oidc-dashboard.recipe.js +463 -0
- package/dist/commands/recipes/oidc-dashboard.recipe.js.map +1 -0
- package/dist/commands/recipes/oidc-resource-server.recipe.d.ts +1 -0
- package/dist/commands/recipes/oidc-resource-server.recipe.js +85 -0
- package/dist/commands/recipes/oidc-resource-server.recipe.js.map +1 -0
- package/dist/commands/recipes/platform-context.recipe.d.ts +1 -0
- package/dist/commands/recipes/platform-context.recipe.js +229 -0
- package/dist/commands/recipes/platform-context.recipe.js.map +1 -0
- package/dist/commands/recipes/platform-parc-authorization.recipe.d.ts +1 -0
- package/dist/commands/recipes/platform-parc-authorization.recipe.js +339 -0
- package/dist/commands/recipes/platform-parc-authorization.recipe.js.map +1 -0
- package/dist/commands/recipes/platform-parc-bound-authorization.recipe.d.ts +1 -0
- package/dist/commands/recipes/platform-parc-bound-authorization.recipe.js +56 -0
- package/dist/commands/recipes/platform-parc-bound-authorization.recipe.js.map +1 -0
- package/dist/commands/recipes/platform-service-access-request-context.recipe.d.ts +1 -0
- package/dist/commands/recipes/platform-service-access-request-context.recipe.js +103 -0
- package/dist/commands/recipes/platform-service-access-request-context.recipe.js.map +1 -0
- package/dist/commands/recipes/platform-service-runtime.recipe.d.ts +1 -0
- package/dist/commands/recipes/platform-service-runtime.recipe.js +415 -0
- package/dist/commands/recipes/platform-service-runtime.recipe.js.map +1 -0
- package/dist/index.js +77 -9
- package/dist/index.js.map +1 -1
- package/dist/templates/ai-context/CLAUDE.md.hbs +39 -3
- package/dist/templates/ai-context/conventions.md.hbs +13 -8
- package/dist/templates/command/create-command.hbs +8 -3
- package/dist/templates/command/delete-command.hbs +1 -1
- package/dist/templates/command/update-command.hbs +1 -1
- package/dist/templates/controller/application-controller.hbs +7 -0
- package/dist/templates/controller/controller.hbs +6 -0
- package/dist/templates/dto/create-dto.hbs +17 -5
- package/dist/templates/dto/filter-dto.hbs +1 -1
- package/dist/templates/dto/response-dto.hbs +1 -1
- package/dist/templates/entity/entity.hbs +4 -0
- package/dist/templates/mapper/mapper.hbs +5 -1
- package/dist/templates/orm-entity/orm-entity.hbs +9 -1
- package/dist/templates/prisma/prisma-mapper.hbs +6 -0
- package/dist/templates/prisma/prisma-repository.hbs +57 -5
- package/dist/templates/prisma/prisma-schema.hbs +2 -0
- package/dist/templates/query/get-all.query.hbs +10 -2
- package/dist/templates/recipes/bounded-json-http/README.md.hbs +36 -0
- package/dist/templates/recipes/external-projection-worker/README.md.hbs +157 -0
- package/dist/templates/recipes/external-projection-worker/canonical-json.ts.hbs +123 -0
- package/dist/templates/recipes/external-projection-worker/enqueuer.ts.hbs +167 -0
- package/dist/templates/recipes/external-projection-worker/index.ts.hbs +6 -0
- package/dist/templates/recipes/external-projection-worker/migration.ts.hbs +88 -0
- package/dist/templates/recipes/external-projection-worker/module.ts.hbs +36 -0
- package/dist/templates/recipes/external-projection-worker/store.ts.hbs +354 -0
- package/dist/templates/recipes/external-projection-worker/types.ts.hbs +134 -0
- package/dist/templates/recipes/external-projection-worker/worker.ts.hbs +296 -0
- package/dist/templates/recipes/kafka-consumer/README.md.hbs +50 -0
- package/dist/templates/recipes/kafka-consumer/kafka-consumer.ts.hbs +135 -0
- package/dist/templates/recipes/kafka-consumer/migration.ts.hbs +17 -0
- package/dist/templates/recipes/oauth2-client-credentials/README.md.hbs +40 -0
- package/dist/templates/recipes/oauth2-client-credentials/client.spec.ts.hbs +234 -0
- package/dist/templates/recipes/oauth2-client-credentials/client.ts.hbs +210 -0
- package/dist/templates/recipes/oauth2-client-credentials/index.ts.hbs +2 -0
- package/dist/templates/recipes/oauth2-client-credentials/module.ts.hbs +58 -0
- package/dist/templates/recipes/oauth2-token-introspection/README.md.hbs +158 -0
- package/dist/templates/recipes/oauth2-token-introspection/client.spec.ts.hbs +350 -0
- package/dist/templates/recipes/oauth2-token-introspection/client.ts.hbs +241 -0
- package/dist/templates/recipes/oauth2-token-introspection/http-client.spec.ts.hbs +411 -0
- package/dist/templates/recipes/oauth2-token-introspection/http-client.ts.hbs +343 -0
- package/dist/templates/recipes/oauth2-token-introspection/index.ts.hbs +3 -0
- package/dist/templates/recipes/oauth2-token-introspection/module.ts.hbs +58 -0
- package/dist/templates/recipes/oidc-bff-session/README.md.hbs +96 -0
- package/dist/templates/recipes/oidc-bff-session/client.test.ts.hbs +663 -0
- package/dist/templates/recipes/oidc-bff-session/client.ts.hbs +635 -0
- package/dist/templates/recipes/oidc-bff-session/index.ts.hbs +3 -0
- package/dist/templates/recipes/oidc-bff-session/store.integration.test.ts.hbs +480 -0
- package/dist/templates/recipes/oidc-bff-session/store.test.ts.hbs +87 -0
- package/dist/templates/recipes/oidc-bff-session/store.ts.hbs +549 -0
- package/dist/templates/recipes/oidc-bff-session/valkey.test.ts.hbs +158 -0
- package/dist/templates/recipes/oidc-bff-session/valkey.ts.hbs +148 -0
- package/dist/templates/recipes/oidc-resource-server/README.md.hbs +74 -0
- package/dist/templates/recipes/oidc-resource-server/index.ts.hbs +2 -0
- package/dist/templates/recipes/oidc-resource-server/module.ts.hbs +51 -0
- package/dist/templates/recipes/oidc-resource-server/verifier.spec.ts.hbs +186 -0
- package/dist/templates/recipes/oidc-resource-server/verifier.ts.hbs +440 -0
- package/dist/templates/recipes/platform-parc-bound-authorization/README.md.hbs +92 -0
- package/dist/templates/recipes/platform-parc-bound-authorization/client.ts.hbs +142 -0
- package/dist/templates/recipes/platform-service-access-request-context/docs.md.hbs +61 -0
- package/dist/templates/recipes/platform-service-access-request-context/env.example.hbs +7 -0
- package/dist/templates/recipes/platform-service-access-request-context/index.ts.hbs +7 -0
- package/dist/templates/recipes/platform-service-access-request-context/request-context-headers.ts.hbs +61 -0
- package/dist/templates/recipes/platform-service-access-request-context/request-context-introspection.parser.ts.hbs +132 -0
- package/dist/templates/recipes/platform-service-access-request-context/request-context-introspection.types.ts.hbs +40 -0
- package/dist/templates/recipes/platform-service-access-request-context/service-access-request-context.client.ts.hbs +45 -0
- package/dist/templates/recipes/platform-service-access-request-context/service-access-request-context.config.ts.hbs +142 -0
- package/dist/templates/recipes/platform-service-access-request-context/service-access-request-context.guard.ts.hbs +77 -0
- package/dist/templates/recipes/platform-service-access-request-context/service-access-request-context.module.ts.hbs +17 -0
- package/dist/templates/repository/repository.hbs +42 -4
- package/dist/templates/resolver/graphql-input.hbs +23 -6
- package/dist/templates/resolver/resolver.hbs +4 -0
- package/dist/templates/service/domain-service.hbs +2 -2
- package/dist/templates/test/controller.spec.hbs +5 -1
- package/dist/templates/test/prisma-repository.spec.hbs +192 -0
- package/dist/templates/test/repository.spec.hbs +40 -1
- package/dist/templates/test/usecase.spec.hbs +23 -4
- package/dist/templates/usecase/application-usecase.hbs +17 -0
- package/dist/templates/usecase/create-usecase.hbs +34 -3
- package/dist/templates/usecase/update-usecase.hbs +15 -1
- package/dist/utils/config.utils.d.ts +2 -0
- package/dist/utils/config.utils.js +14 -2
- package/dist/utils/config.utils.js.map +1 -1
- package/dist/utils/dependency.utils.d.ts +16 -2
- package/dist/utils/dependency.utils.js +100 -12
- package/dist/utils/dependency.utils.js.map +1 -1
- package/dist/utils/field.utils.d.ts +6 -0
- package/dist/utils/field.utils.js +50 -8
- package/dist/utils/field.utils.js.map +1 -1
- package/dist/utils/field.utils.spec.d.ts +1 -0
- package/dist/utils/field.utils.spec.js +38 -0
- package/dist/utils/field.utils.spec.js.map +1 -0
- package/dist/utils/file.utils.d.ts +23 -1
- package/dist/utils/file.utils.js +37 -1
- package/dist/utils/file.utils.js.map +1 -1
- package/dist/utils/naming.utils.js +5 -4
- package/dist/utils/naming.utils.js.map +1 -1
- package/dist/utils/static-recipe.utils.d.ts +4 -0
- package/dist/utils/static-recipe.utils.js +148 -0
- package/dist/utils/static-recipe.utils.js.map +1 -0
- package/package.json +12 -5
|
@@ -0,0 +1,480 @@
|
|
|
1
|
+
import { afterAll, beforeAll, expect, test } from 'bun:test';
|
|
2
|
+
import { createClient } from '@redis/client';
|
|
3
|
+
import { createValkeySessionCommands } from './valkey-session-store';
|
|
4
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
5
|
+
import {
|
|
6
|
+
OpaqueSessionStore,
|
|
7
|
+
SESSION_SECONDS,
|
|
8
|
+
TOKEN_RETENTION_SECONDS,
|
|
9
|
+
type PendingLogin,
|
|
10
|
+
} from './opaque-session-store';
|
|
11
|
+
|
|
12
|
+
const address = process.env.OIDC_BFF_TEST_VALKEY_URL;
|
|
13
|
+
const integration = address ? test : test.skip;
|
|
14
|
+
const client = createClient({
|
|
15
|
+
url: address ?? 'redis://127.0.0.1:16380',
|
|
16
|
+
disableOfflineQueue: true,
|
|
17
|
+
socket: { connectTimeout: 1000, reconnectStrategy: false },
|
|
18
|
+
});
|
|
19
|
+
client.on('error', () => {});
|
|
20
|
+
beforeAll(async () => {
|
|
21
|
+
if (address) await client.connect();
|
|
22
|
+
});
|
|
23
|
+
afterAll(() => {
|
|
24
|
+
if (client.isOpen) client.destroy();
|
|
25
|
+
});
|
|
26
|
+
const commands = {
|
|
27
|
+
eval: (script: string, options: { keys: string[]; arguments: string[] }) =>
|
|
28
|
+
client.withAbortSignal(AbortSignal.timeout(1000)).eval(script, options),
|
|
29
|
+
};
|
|
30
|
+
function fixture() {
|
|
31
|
+
return new OpaqueSessionStore(commands, `synthetic-${randomUUID()}`);
|
|
32
|
+
}
|
|
33
|
+
function pair() {
|
|
34
|
+
return {
|
|
35
|
+
accessToken: 'synthetic_opaque_access',
|
|
36
|
+
refreshToken: 'synthetic_opaque_refresh',
|
|
37
|
+
expiresAt: Math.floor(Date.now() / 1000) + 3600,
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
async function login(store: OpaqueSessionStore) {
|
|
41
|
+
const start = (await store.beginLogin({ next: '/board' }))!;
|
|
42
|
+
const pending = (await store.takeLogin(
|
|
43
|
+
start.pendingHandle,
|
|
44
|
+
start.browser,
|
|
45
|
+
start.generation,
|
|
46
|
+
start.pending.state,
|
|
47
|
+
))!;
|
|
48
|
+
const sid = (await store.completeLogin(pending, pair()))!;
|
|
49
|
+
return { ...start, pending, sid };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
integration('opaque cookies hold neither access/refresh tokens nor PKCE verifier', async () => {
|
|
53
|
+
const store = fixture();
|
|
54
|
+
const flow = await login(store);
|
|
55
|
+
expect(flow.sid).toMatch(/^v3\.[A-Za-z0-9_-]{43}$/);
|
|
56
|
+
expect(flow.pendingHandle).toMatch(/^[A-Za-z0-9_-]{43}$/);
|
|
57
|
+
const cookies = [flow.sid, flow.pendingHandle, flow.browser, flow.generation].join(';');
|
|
58
|
+
for (const secret of [
|
|
59
|
+
pair().accessToken,
|
|
60
|
+
pair().refreshToken,
|
|
61
|
+
flow.pending.verifier,
|
|
62
|
+
flow.pending.nonce,
|
|
63
|
+
]) {
|
|
64
|
+
expect(cookies).not.toContain(secret);
|
|
65
|
+
}
|
|
66
|
+
expect((await store.read(flow.sid, flow.generation))?.accessToken).toBe(pair().accessToken);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
integration('pending state has one concurrent consumer and one completion', async () => {
|
|
70
|
+
const store = fixture();
|
|
71
|
+
const start = (await store.beginLogin({ next: '/' }))!;
|
|
72
|
+
const replies = await Promise.all(
|
|
73
|
+
Array.from({ length: 8 }, () =>
|
|
74
|
+
store.takeLogin(start.pendingHandle, start.browser, start.generation, start.pending.state),
|
|
75
|
+
),
|
|
76
|
+
);
|
|
77
|
+
const claimed = replies.filter((value): value is PendingLogin => value !== null);
|
|
78
|
+
expect(claimed).toHaveLength(1);
|
|
79
|
+
const completions = await Promise.all(
|
|
80
|
+
Array.from({ length: 8 }, () => store.completeLogin(claimed[0], pair())),
|
|
81
|
+
);
|
|
82
|
+
expect(completions.filter(Boolean)).toHaveLength(1);
|
|
83
|
+
expect(await store.completeLogin(start.pending, pair())).toBeNull();
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
integration(
|
|
87
|
+
'wrong browser, state, generation and client cannot consume pending login',
|
|
88
|
+
async () => {
|
|
89
|
+
const store = fixture();
|
|
90
|
+
const foreign = fixture();
|
|
91
|
+
const start = (await store.beginLogin({ next: '/' }))!;
|
|
92
|
+
const wrong = 'a'.repeat(43);
|
|
93
|
+
expect(
|
|
94
|
+
await store.takeLogin(start.pendingHandle, wrong, start.generation, start.pending.state),
|
|
95
|
+
).toBeNull();
|
|
96
|
+
expect(
|
|
97
|
+
await store.takeLogin(start.pendingHandle, start.browser, wrong, start.pending.state),
|
|
98
|
+
).toBeNull();
|
|
99
|
+
expect(
|
|
100
|
+
await store.takeLogin(start.pendingHandle, start.browser, start.generation, wrong),
|
|
101
|
+
).toBeNull();
|
|
102
|
+
expect(
|
|
103
|
+
await foreign.takeLogin(
|
|
104
|
+
start.pendingHandle,
|
|
105
|
+
start.browser,
|
|
106
|
+
start.generation,
|
|
107
|
+
start.pending.state,
|
|
108
|
+
),
|
|
109
|
+
).toBeNull();
|
|
110
|
+
expect(
|
|
111
|
+
await store.takeLogin(
|
|
112
|
+
start.pendingHandle,
|
|
113
|
+
start.browser,
|
|
114
|
+
start.generation,
|
|
115
|
+
start.pending.state,
|
|
116
|
+
),
|
|
117
|
+
).not.toBeNull();
|
|
118
|
+
},
|
|
119
|
+
);
|
|
120
|
+
|
|
121
|
+
integration(
|
|
122
|
+
'logout invalidates both pending callbacks and callbacks already exchanging a code',
|
|
123
|
+
async () => {
|
|
124
|
+
const store = fixture();
|
|
125
|
+
const start = (await store.beginLogin({ next: '/' }))!;
|
|
126
|
+
const pending = (await store.takeLogin(
|
|
127
|
+
start.pendingHandle,
|
|
128
|
+
start.browser,
|
|
129
|
+
start.generation,
|
|
130
|
+
start.pending.state,
|
|
131
|
+
))!;
|
|
132
|
+
const logout = (await store.logout(start.browser, undefined, start.pendingHandle))!;
|
|
133
|
+
expect(logout.generation).not.toBe(start.generation);
|
|
134
|
+
expect(await store.completeLogin(pending, pair())).toBeNull();
|
|
135
|
+
expect(
|
|
136
|
+
await store.takeLogin(
|
|
137
|
+
start.pendingHandle,
|
|
138
|
+
start.browser,
|
|
139
|
+
start.generation,
|
|
140
|
+
start.pending.state,
|
|
141
|
+
),
|
|
142
|
+
).toBeNull();
|
|
143
|
+
expect(
|
|
144
|
+
await store.beginLogin({ browser: start.browser, generation: start.generation, next: '/' }),
|
|
145
|
+
).toBeNull();
|
|
146
|
+
expect(
|
|
147
|
+
await store.beginLogin({ browser: start.browser, generation: logout.generation, next: '/' }),
|
|
148
|
+
).not.toBeNull();
|
|
149
|
+
},
|
|
150
|
+
);
|
|
151
|
+
|
|
152
|
+
integration(
|
|
153
|
+
'a delayed callback cookie cannot revive any old browser session after logout',
|
|
154
|
+
async () => {
|
|
155
|
+
const store = fixture();
|
|
156
|
+
const first = await login(store);
|
|
157
|
+
const second = (await store.beginLogin({
|
|
158
|
+
browser: first.browser,
|
|
159
|
+
generation: first.generation,
|
|
160
|
+
next: '/',
|
|
161
|
+
}))!;
|
|
162
|
+
const pending = (await store.takeLogin(
|
|
163
|
+
second.pendingHandle,
|
|
164
|
+
first.browser,
|
|
165
|
+
first.generation,
|
|
166
|
+
second.pending.state,
|
|
167
|
+
))!;
|
|
168
|
+
const delayedSid = (await store.completeLogin(pending, pair()))!;
|
|
169
|
+
const logout = (await store.logout(first.browser, first.sid))!;
|
|
170
|
+
expect(logout.tokens?.refreshToken).toBe(pair().refreshToken);
|
|
171
|
+
expect(await store.read(first.sid, first.generation)).toBeNull();
|
|
172
|
+
expect(await store.read(delayedSid, first.generation)).toBeNull();
|
|
173
|
+
expect(await store.read(delayedSid, logout.generation)).toBeNull();
|
|
174
|
+
},
|
|
175
|
+
);
|
|
176
|
+
|
|
177
|
+
integration('Three application logout generations remain independent', async () => {
|
|
178
|
+
const first = fixture(),
|
|
179
|
+
second = fixture(),
|
|
180
|
+
third = fixture();
|
|
181
|
+
const a = await login(first),
|
|
182
|
+
b = await login(second),
|
|
183
|
+
c = await login(third);
|
|
184
|
+
expect(await second.read(a.sid, a.generation)).toBeNull();
|
|
185
|
+
expect(await first.read(b.sid, b.generation)).toBeNull();
|
|
186
|
+
await first.logout(a.browser, a.sid, a.pendingHandle);
|
|
187
|
+
expect(await first.read(a.sid, a.generation)).toBeNull();
|
|
188
|
+
expect((await second.read(b.sid, b.generation))?.accessToken).toBe(pair().accessToken);
|
|
189
|
+
expect((await third.read(c.sid, c.generation))?.accessToken).toBe(pair().accessToken);
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
integration(
|
|
193
|
+
'a fresh account switch supersedes earlier callbacks and accepts a stale cookie generation safely',
|
|
194
|
+
async () => {
|
|
195
|
+
const store = fixture();
|
|
196
|
+
const first = await login(store);
|
|
197
|
+
const older = (await store.beginLogin({
|
|
198
|
+
browser: first.browser,
|
|
199
|
+
generation: first.generation,
|
|
200
|
+
next: '/board',
|
|
201
|
+
}))!;
|
|
202
|
+
const claimed = (await store.takeLogin(
|
|
203
|
+
older.pendingHandle,
|
|
204
|
+
older.browser,
|
|
205
|
+
older.generation,
|
|
206
|
+
older.pending.state,
|
|
207
|
+
))!;
|
|
208
|
+
const fresh = (await store.beginLogin({
|
|
209
|
+
browser: first.browser,
|
|
210
|
+
generation: first.generation,
|
|
211
|
+
next: '/account',
|
|
212
|
+
fresh: true,
|
|
213
|
+
}))!;
|
|
214
|
+
expect(fresh.generation).not.toBe(first.generation);
|
|
215
|
+
expect(await store.read(first.sid, first.generation)).toBeNull();
|
|
216
|
+
expect(await store.completeLogin(claimed, pair())).toBeNull();
|
|
217
|
+
const pending = (await store.takeLogin(
|
|
218
|
+
fresh.pendingHandle,
|
|
219
|
+
fresh.browser,
|
|
220
|
+
fresh.generation,
|
|
221
|
+
fresh.pending.state,
|
|
222
|
+
))!;
|
|
223
|
+
const sid = (await store.completeLogin(pending, pair()))!;
|
|
224
|
+
expect((await store.read(sid, fresh.generation))?.accessToken).toBe(pair().accessToken);
|
|
225
|
+
// A delayed old Set-Cookie can cause a retry, but cannot restore an old actor.
|
|
226
|
+
expect(await store.read(sid, first.generation)).toBeNull();
|
|
227
|
+
expect(
|
|
228
|
+
await store.beginLogin({
|
|
229
|
+
browser: first.browser,
|
|
230
|
+
generation: first.generation,
|
|
231
|
+
next: '/',
|
|
232
|
+
fresh: true,
|
|
233
|
+
}),
|
|
234
|
+
).not.toBeNull();
|
|
235
|
+
},
|
|
236
|
+
);
|
|
237
|
+
|
|
238
|
+
integration('another BFF instance can read a session with bounded absolute expiry', async () => {
|
|
239
|
+
const id = `synthetic-${randomUUID()}`;
|
|
240
|
+
const store = new OpaqueSessionStore(commands, id);
|
|
241
|
+
const flow = await login(store);
|
|
242
|
+
const restarted = new OpaqueSessionStore(commands, id);
|
|
243
|
+
expect((await restarted.read(flow.sid, flow.generation))?.accessToken).toBe(pair().accessToken);
|
|
244
|
+
const keys = await client.keys(
|
|
245
|
+
`oidc:bff:{${createHash('sha256').update(id).digest('hex')}}:session:*`,
|
|
246
|
+
);
|
|
247
|
+
const ttls = await Promise.all(keys.map((key) => client.ttl(key)));
|
|
248
|
+
expect(ttls.every((ttl) => ttl > 0 && ttl <= TOKEN_RETENTION_SECONDS)).toBe(true);
|
|
249
|
+
const logicalExpiry = new OpaqueSessionStore(
|
|
250
|
+
commands,
|
|
251
|
+
id,
|
|
252
|
+
() => Math.floor(Date.now() / 1000) + SESSION_SECONDS,
|
|
253
|
+
);
|
|
254
|
+
expect(await logicalExpiry.read(flow.sid, flow.generation)).toBeNull();
|
|
255
|
+
const expired = new OpaqueSessionStore(commands, id, () => pair().expiresAt + 1);
|
|
256
|
+
expect(await expired.read(flow.sid, flow.generation)).toBeNull();
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
integration(
|
|
260
|
+
'logout atomically retains own tokens for restart-safe revocation without clearing sibling applications',
|
|
261
|
+
async () => {
|
|
262
|
+
const id = `synthetic-${randomUUID()}`;
|
|
263
|
+
const store = new OpaqueSessionStore(commands, id);
|
|
264
|
+
const siblingStore = fixture();
|
|
265
|
+
const firstFlow = await login(store),
|
|
266
|
+
sibling = await login(siblingStore);
|
|
267
|
+
const retired = (await store.logout(firstFlow.browser, firstFlow.sid))!;
|
|
268
|
+
expect(retired.revocationTicket).toMatch(/^[A-Za-z0-9_-]{43}$/);
|
|
269
|
+
expect(await store.read(firstFlow.sid, firstFlow.generation)).toBeNull();
|
|
270
|
+
expect(await siblingStore.claimRevocation(retired.revocationTicket!)).toBeNull();
|
|
271
|
+
expect(await siblingStore.read(sibling.sid, sibling.generation)).not.toBeNull();
|
|
272
|
+
const restarted = new OpaqueSessionStore(commands, id);
|
|
273
|
+
const claims = await Promise.all(
|
|
274
|
+
Array.from({ length: 6 }, () => restarted.claimRevocation(retired.revocationTicket!)),
|
|
275
|
+
);
|
|
276
|
+
const claim = claims.find(Boolean)!;
|
|
277
|
+
expect(claims.filter(Boolean)).toHaveLength(1);
|
|
278
|
+
expect(claim.tokens).toEqual(pair());
|
|
279
|
+
expect(await restarted.settleRevocation({ ...claim, lease: 'a'.repeat(43) }, true)).toBe(false);
|
|
280
|
+
expect(await restarted.settleRevocation(claim, false)).toBe(true);
|
|
281
|
+
expect(await restarted.claimRevocation()).toBeNull();
|
|
282
|
+
const retry = (await restarted.claimRevocation(retired.revocationTicket!))!;
|
|
283
|
+
expect(await restarted.settleRevocation(retry, true)).toBe(true);
|
|
284
|
+
expect(await restarted.claimRevocation(retired.revocationTicket!)).toBeNull();
|
|
285
|
+
},
|
|
286
|
+
);
|
|
287
|
+
|
|
288
|
+
integration(
|
|
289
|
+
'minted tokens transfer to a session atomically only after exact pending and pair binding',
|
|
290
|
+
async () => {
|
|
291
|
+
const store = fixture();
|
|
292
|
+
const start = (await store.beginLogin({ next: '/', fresh: true }))!;
|
|
293
|
+
const pending = (await store.takeLogin(
|
|
294
|
+
start.pendingHandle,
|
|
295
|
+
start.browser,
|
|
296
|
+
start.generation,
|
|
297
|
+
start.pending.state,
|
|
298
|
+
))!;
|
|
299
|
+
const ticket = await store.queueRevocation(pair(), 600);
|
|
300
|
+
expect(await store.claimRevocation()).toBeNull();
|
|
301
|
+
expect(
|
|
302
|
+
await store.completeLogin(pending, { ...pair(), accessToken: 'wrong-minted-pair' }, ticket),
|
|
303
|
+
).toBeNull();
|
|
304
|
+
const sid = (await store.completeLogin(pending, pair(), ticket))!;
|
|
305
|
+
expect(await store.read(sid, start.generation)).not.toBeNull();
|
|
306
|
+
expect(await store.claimRevocation(ticket)).toBeNull();
|
|
307
|
+
const fresh = (await store.beginLogin({
|
|
308
|
+
browser: start.browser,
|
|
309
|
+
generation: start.generation,
|
|
310
|
+
next: '/',
|
|
311
|
+
fresh: true,
|
|
312
|
+
}))!;
|
|
313
|
+
expect(await store.read(sid, start.generation)).toBeNull();
|
|
314
|
+
const retirement = (await store.claimRevocation())!;
|
|
315
|
+
expect(retirement.tokens).toEqual(pair());
|
|
316
|
+
expect(await store.settleRevocation(retirement, true)).toBe(true);
|
|
317
|
+
expect(fresh.generation).not.toBe(start.generation);
|
|
318
|
+
},
|
|
319
|
+
);
|
|
320
|
+
|
|
321
|
+
integration('an expired browser session retains refresh custody until its own logout', async () => {
|
|
322
|
+
const id = `synthetic-${randomUUID()}`;
|
|
323
|
+
const store = new OpaqueSessionStore(commands, id);
|
|
324
|
+
const flow = await login(store);
|
|
325
|
+
const later = new OpaqueSessionStore(commands, id, () => pair().expiresAt + 1);
|
|
326
|
+
expect(await later.read(flow.sid, flow.generation)).toBeNull();
|
|
327
|
+
const retired = (await later.logout(flow.browser, flow.sid))!;
|
|
328
|
+
const claim = (await later.claimRevocation(retired.revocationTicket!))!;
|
|
329
|
+
expect(claim.tokens.refreshToken).toBe(pair().refreshToken);
|
|
330
|
+
expect(await later.settleRevocation(claim, true)).toBe(true);
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
integration('storage outages fail closed and do not manufacture an anonymous session', async () => {
|
|
334
|
+
const store = new OpaqueSessionStore(
|
|
335
|
+
{
|
|
336
|
+
eval: async () => {
|
|
337
|
+
throw new Error('synthetic unavailable');
|
|
338
|
+
},
|
|
339
|
+
},
|
|
340
|
+
'outage',
|
|
341
|
+
);
|
|
342
|
+
await expect(store.beginLogin({ next: '/' })).rejects.toThrow('synthetic unavailable');
|
|
343
|
+
await expect(store.read(`v3.${'a'.repeat(43)}`, 'b'.repeat(43))).rejects.toThrow(
|
|
344
|
+
'synthetic unavailable',
|
|
345
|
+
);
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
test('malformed handles and external return paths are rejected without storage access', async () => {
|
|
349
|
+
let accesses = 0;
|
|
350
|
+
const store = new OpaqueSessionStore(
|
|
351
|
+
{
|
|
352
|
+
eval: async () => {
|
|
353
|
+
accesses++;
|
|
354
|
+
return null;
|
|
355
|
+
},
|
|
356
|
+
},
|
|
357
|
+
'safe-input',
|
|
358
|
+
);
|
|
359
|
+
expect(await store.read('signed.access.token', 'a'.repeat(43))).toBeNull();
|
|
360
|
+
expect(
|
|
361
|
+
await store.takeLogin('invalid', 'a'.repeat(43), 'b'.repeat(43), 'c'.repeat(43)),
|
|
362
|
+
).toBeNull();
|
|
363
|
+
for (const next of ['https://foreign.test', '//foreign.test', '/\\foreign', '/bad\npath']) {
|
|
364
|
+
await expect(store.beginLogin({ next })).rejects.toThrow('Invalid return path');
|
|
365
|
+
}
|
|
366
|
+
expect(accesses).toBe(0);
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
integration(
|
|
370
|
+
'expired leases are reclaimable after restart while stale owners cannot acknowledge successors',
|
|
371
|
+
async () => {
|
|
372
|
+
const id = `synthetic-${randomUUID()}`;
|
|
373
|
+
const store = new OpaqueSessionStore(commands, id, undefined, {
|
|
374
|
+
leaseSeconds: 1,
|
|
375
|
+
retrySeconds: 1,
|
|
376
|
+
});
|
|
377
|
+
const ticket = await store.queueRevocation({
|
|
378
|
+
refreshToken: 'synthetic-refresh-only',
|
|
379
|
+
expiresAt: 1,
|
|
380
|
+
});
|
|
381
|
+
const first = (await store.claimRevocation(ticket))!;
|
|
382
|
+
await new Promise((resolve) => setTimeout(resolve, 1100));
|
|
383
|
+
const restarted = new OpaqueSessionStore(commands, id, undefined, {
|
|
384
|
+
leaseSeconds: 1,
|
|
385
|
+
retrySeconds: 1,
|
|
386
|
+
});
|
|
387
|
+
const successor = (await restarted.claimRevocation(ticket))!;
|
|
388
|
+
expect(successor.lease).not.toBe(first.lease);
|
|
389
|
+
expect(await store.settleRevocation(first, true)).toBe(false);
|
|
390
|
+
expect(await restarted.revocationPending(ticket)).toBe(true);
|
|
391
|
+
expect(await restarted.settleRevocation(successor, true)).toBe(true);
|
|
392
|
+
expect(await restarted.revocationPending(ticket)).toBe(false);
|
|
393
|
+
},
|
|
394
|
+
);
|
|
395
|
+
|
|
396
|
+
integration(
|
|
397
|
+
'access-only retirement is durable and cannot manufacture an authenticated login',
|
|
398
|
+
async () => {
|
|
399
|
+
const store = fixture();
|
|
400
|
+
const ticket = await store.queueRevocation({
|
|
401
|
+
accessToken: 'synthetic-access-only',
|
|
402
|
+
expiresAt: 1,
|
|
403
|
+
});
|
|
404
|
+
const claim = (await store.claimRevocation(ticket))!;
|
|
405
|
+
expect(claim.tokens.refreshToken).toBeUndefined();
|
|
406
|
+
expect(claim.tokens.accessToken).toBe('synthetic-access-only');
|
|
407
|
+
expect(await store.settleRevocation(claim, true)).toBe(true);
|
|
408
|
+
},
|
|
409
|
+
);
|
|
410
|
+
|
|
411
|
+
integration(
|
|
412
|
+
'the bounded Valkey adapter preserves real Lua custody across a new connection owner',
|
|
413
|
+
async () => {
|
|
414
|
+
const namespace = `synthetic-adapter-${randomUUID()}`;
|
|
415
|
+
const first = createValkeySessionCommands({ url: address!, trustedInternalPlaintext: true });
|
|
416
|
+
const second = createValkeySessionCommands({ url: address!, trustedInternalPlaintext: true });
|
|
417
|
+
try {
|
|
418
|
+
const initial = new OpaqueSessionStore(first, namespace);
|
|
419
|
+
const ticket = await initial.queueRevocation({
|
|
420
|
+
refreshToken: 'synthetic_refresh_only',
|
|
421
|
+
expiresAt: 1,
|
|
422
|
+
});
|
|
423
|
+
first.close();
|
|
424
|
+
const restarted = new OpaqueSessionStore(second, namespace);
|
|
425
|
+
const claim = await restarted.claimRevocation(ticket);
|
|
426
|
+
expect(claim?.tokens.refreshToken).toBe('synthetic_refresh_only');
|
|
427
|
+
expect(claim?.tokens.accessToken).toBeUndefined();
|
|
428
|
+
expect(await restarted.settleRevocation(claim!, true)).toBe(true);
|
|
429
|
+
expect(await restarted.revocationPending(ticket)).toBe(false);
|
|
430
|
+
} finally {
|
|
431
|
+
first.close();
|
|
432
|
+
second.close();
|
|
433
|
+
}
|
|
434
|
+
},
|
|
435
|
+
);
|
|
436
|
+
|
|
437
|
+
integration(
|
|
438
|
+
'session admission refuses a staged pair while a revocation worker owns its lease',
|
|
439
|
+
async () => {
|
|
440
|
+
const store = fixture();
|
|
441
|
+
const start = (await store.beginLogin({ next: '/', fresh: true }))!;
|
|
442
|
+
const pending = (await store.takeLogin(
|
|
443
|
+
start.pendingHandle,
|
|
444
|
+
start.browser,
|
|
445
|
+
start.generation,
|
|
446
|
+
start.pending.state,
|
|
447
|
+
))!;
|
|
448
|
+
const tokens = pair();
|
|
449
|
+
const ticket = await store.queueRevocation(tokens, 600);
|
|
450
|
+
const claim = (await store.claimRevocation(ticket))!;
|
|
451
|
+
expect(await store.completeLogin(pending, tokens, ticket)).toBeNull();
|
|
452
|
+
expect(await store.revocationPending(ticket)).toBe(true);
|
|
453
|
+
expect(await store.settleRevocation(claim, false)).toBe(true);
|
|
454
|
+
expect(await store.completeLogin(pending, tokens, ticket)).toBeNull();
|
|
455
|
+
const retry = (await store.claimRevocation(ticket))!;
|
|
456
|
+
expect(await store.settleRevocation(retry, true)).toBe(true);
|
|
457
|
+
expect(await store.completeLogin(pending, tokens, ticket)).toBeNull();
|
|
458
|
+
},
|
|
459
|
+
);
|
|
460
|
+
|
|
461
|
+
integration('expired retirement leases cannot restore session admissibility', async () => {
|
|
462
|
+
const namespace = `synthetic-fencing-${randomUUID()}`;
|
|
463
|
+
const store = new OpaqueSessionStore(commands, namespace, undefined, { leaseSeconds: 1 });
|
|
464
|
+
const start = (await store.beginLogin({ next: '/', fresh: true }))!;
|
|
465
|
+
const pending = (await store.takeLogin(
|
|
466
|
+
start.pendingHandle,
|
|
467
|
+
start.browser,
|
|
468
|
+
start.generation,
|
|
469
|
+
start.pending.state,
|
|
470
|
+
))!;
|
|
471
|
+
const tokens = pair();
|
|
472
|
+
const ticket = await store.queueRevocation(tokens, 600);
|
|
473
|
+
const claim = (await store.claimRevocation(ticket))!;
|
|
474
|
+
await new Promise((resolve) => setTimeout(resolve, 1100));
|
|
475
|
+
expect(await store.completeLogin(pending, tokens, ticket)).toBeNull();
|
|
476
|
+
const nextClaim = (await store.claimRevocation(ticket))!;
|
|
477
|
+
expect(nextClaim.lease).not.toBe(claim.lease);
|
|
478
|
+
expect(await store.settleRevocation(claim, true)).toBe(false);
|
|
479
|
+
expect(await store.settleRevocation(nextClaim, true)).toBe(true);
|
|
480
|
+
});
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { expect, test } from 'bun:test';
|
|
2
|
+
import { OpaqueSessionStore } from './opaque-session-store';
|
|
3
|
+
|
|
4
|
+
test('invalid handles and return paths never touch storage', async () => {
|
|
5
|
+
let accesses = 0;
|
|
6
|
+
const store = new OpaqueSessionStore(
|
|
7
|
+
{
|
|
8
|
+
eval: async () => {
|
|
9
|
+
accesses++;
|
|
10
|
+
return null;
|
|
11
|
+
},
|
|
12
|
+
},
|
|
13
|
+
'synthetic-client',
|
|
14
|
+
);
|
|
15
|
+
expect(await store.read('raw.access.token', 'a'.repeat(43))).toBeNull();
|
|
16
|
+
expect(
|
|
17
|
+
await store.takeLogin('invalid', 'a'.repeat(43), 'b'.repeat(43), 'c'.repeat(43)),
|
|
18
|
+
).toBeNull();
|
|
19
|
+
for (const next of ['https://foreign.test', '//foreign.test', '/\\foreign', '/bad\npath']) {
|
|
20
|
+
await expect(store.beginLogin({ next })).rejects.toThrow('Invalid return path');
|
|
21
|
+
}
|
|
22
|
+
expect(accesses).toBe(0);
|
|
23
|
+
});
|
|
24
|
+
test('settings are immutable, bounded and preserve custody beyond the configured provider cutoff', async () => {
|
|
25
|
+
for (const settings of [
|
|
26
|
+
{ tokenRetentionSeconds: 86400 },
|
|
27
|
+
{ maxProviderRefreshSeconds: 90000 },
|
|
28
|
+
{ loginSeconds: 901 },
|
|
29
|
+
{ leaseSeconds: 0 },
|
|
30
|
+
{ sessionSeconds: 3601 },
|
|
31
|
+
{ retrySeconds: 3601 },
|
|
32
|
+
]) {
|
|
33
|
+
expect(
|
|
34
|
+
() =>
|
|
35
|
+
new OpaqueSessionStore(
|
|
36
|
+
{ eval: () => Promise.resolve(null) },
|
|
37
|
+
'client',
|
|
38
|
+
undefined,
|
|
39
|
+
settings,
|
|
40
|
+
),
|
|
41
|
+
).toThrow('duration configuration');
|
|
42
|
+
}
|
|
43
|
+
const settings = { tokenRetentionSeconds: 100000 };
|
|
44
|
+
let argumentsSeen: string[] = [];
|
|
45
|
+
const store = new OpaqueSessionStore(
|
|
46
|
+
{
|
|
47
|
+
eval: (_script, command) => {
|
|
48
|
+
argumentsSeen = command.arguments;
|
|
49
|
+
return Promise.resolve(null);
|
|
50
|
+
},
|
|
51
|
+
},
|
|
52
|
+
'synthetic-client',
|
|
53
|
+
() => 1000,
|
|
54
|
+
settings,
|
|
55
|
+
);
|
|
56
|
+
settings.tokenRetentionSeconds = 1;
|
|
57
|
+
await store.beginLogin({ next: '/' });
|
|
58
|
+
expect(argumentsSeen[7]).toBe('100000');
|
|
59
|
+
});
|
|
60
|
+
test('retirement requires at least one safe credential, while access admission requires an unexpired access token', async () => {
|
|
61
|
+
const store = new OpaqueSessionStore(
|
|
62
|
+
{ eval: () => Promise.resolve(null) },
|
|
63
|
+
'synthetic-client',
|
|
64
|
+
() => 1000,
|
|
65
|
+
);
|
|
66
|
+
for (const tokens of [
|
|
67
|
+
{ expiresAt: 1 },
|
|
68
|
+
{ accessToken: 'bad\ntoken', expiresAt: 1 },
|
|
69
|
+
{ refreshToken: 'r'.repeat(16385), expiresAt: 1 },
|
|
70
|
+
]) {
|
|
71
|
+
await expect(store.queueRevocation(tokens)).rejects.toThrow('retirement record');
|
|
72
|
+
}
|
|
73
|
+
await expect(store.queueRevocation({ accessToken: 'access', expiresAt: 1 }, 601)).rejects.toThrow(
|
|
74
|
+
'retirement record',
|
|
75
|
+
);
|
|
76
|
+
const pending = {
|
|
77
|
+
browser: 'a'.repeat(64),
|
|
78
|
+
generation: 'b'.repeat(43),
|
|
79
|
+
state: 'c'.repeat(43),
|
|
80
|
+
verifier: 'd'.repeat(43),
|
|
81
|
+
nonce: 'e'.repeat(43),
|
|
82
|
+
next: '/',
|
|
83
|
+
startedAt: 1000,
|
|
84
|
+
};
|
|
85
|
+
expect(await store.completeLogin(pending, { accessToken: 'access', expiresAt: 1000 })).toBeNull();
|
|
86
|
+
expect(await store.completeLogin(pending, { accessToken: 'access', expiresAt: 4601 })).toBeNull();
|
|
87
|
+
});
|