nestjs-ddd-cli 3.2.1 → 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 +238 -176
- 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 +44 -35
- 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 +41 -31
- 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 +282 -91
- 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 +12 -0
- package/dist/commands/recipes/index.js +12 -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.d.ts +1 -0
- package/dist/commands/recipes/joi-env.recipe.js +301 -0
- package/dist/commands/recipes/joi-env.recipe.js.map +1 -0
- 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 +82 -11
- package/dist/index.js.map +1 -1
- package/dist/templates/ai-context/CLAUDE.md.hbs +54 -3
- package/dist/templates/ai-context/conventions.md.hbs +20 -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 +6 -2
- 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 +43 -5
- 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 +41 -2
- package/dist/templates/test/usecase.spec.hbs +24 -5
- 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 +18 -2
- package/dist/utils/dependency.utils.js +144 -15
- package/dist/utils/dependency.utils.js.map +1 -1
- package/dist/utils/field.utils.d.ts +6 -0
- package/dist/utils/field.utils.js +78 -24
- 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 +39 -3
- package/dist/utils/file.utils.js +150 -13
- 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/dist/utils/template-engine.utils.js +83 -68
- package/dist/utils/template-engine.utils.js.map +1 -1
- package/package.json +20 -11
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
import {
|
|
2
|
+
OAuth2ClientCredentialsClient,
|
|
3
|
+
OAuth2ClientCredentialsOptions,
|
|
4
|
+
OAuth2ClientCredentialsUnavailableError,
|
|
5
|
+
} from './oauth2-client-credentials.client';
|
|
6
|
+
import { OAuth2ClientCredentialsModule } from './oauth2-client-credentials.module';
|
|
7
|
+
|
|
8
|
+
describe('OAuth2 client credentials', () => {
|
|
9
|
+
const response = (changes: Record<string, unknown> = {}) =>
|
|
10
|
+
new Response(
|
|
11
|
+
JSON.stringify({
|
|
12
|
+
access_token: 'PRIVATE_TOKEN',
|
|
13
|
+
token_type: 'Bearer',
|
|
14
|
+
expires_in: 3600,
|
|
15
|
+
...changes,
|
|
16
|
+
}),
|
|
17
|
+
);
|
|
18
|
+
const create = (options: Partial<OAuth2ClientCredentialsOptions> = {}) =>
|
|
19
|
+
new OAuth2ClientCredentialsClient({
|
|
20
|
+
tokenUrl: 'https://provider.example.test/oauth/v3/token',
|
|
21
|
+
clientId: 'PRIVATE_ID',
|
|
22
|
+
clientSecret: 'PRIVATE_SECRET',
|
|
23
|
+
transport: () => Promise.resolve(response()),
|
|
24
|
+
...options,
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('uses form encoded Basic components and a fixed form grant with snapshotted scopes', async () => {
|
|
28
|
+
const transport = jest.fn((_url: string, _init: RequestInit) => {
|
|
29
|
+
void _url;
|
|
30
|
+
void _init;
|
|
31
|
+
return Promise.resolve(response());
|
|
32
|
+
});
|
|
33
|
+
const scopes = ['messages.send'];
|
|
34
|
+
const client = create({
|
|
35
|
+
clientId: 'client:with space',
|
|
36
|
+
clientSecret: 'secret+value',
|
|
37
|
+
scopes,
|
|
38
|
+
transport,
|
|
39
|
+
});
|
|
40
|
+
scopes.push('admin');
|
|
41
|
+
expect(await client.getAccessToken()).toBe('PRIVATE_TOKEN');
|
|
42
|
+
const call = transport.mock.calls[0];
|
|
43
|
+
if (!call) throw new Error('Missing provider request');
|
|
44
|
+
expect(call[0]).toBe('https://provider.example.test/oauth/v3/token');
|
|
45
|
+
expect(call[1]).toMatchObject({
|
|
46
|
+
method: 'POST',
|
|
47
|
+
redirect: 'error',
|
|
48
|
+
cache: 'no-store',
|
|
49
|
+
body: 'grant_type=client_credentials&scope=messages.send',
|
|
50
|
+
});
|
|
51
|
+
expect(call[1].headers).toMatchObject({
|
|
52
|
+
authorization: `Basic ${Buffer.from('client%3Awith+space:secret%2Bvalue').toString('base64')}`,
|
|
53
|
+
'content-type': 'application/x-www-form-urlencoded',
|
|
54
|
+
});
|
|
55
|
+
expect(JSON.stringify(client)).toBe('{}');
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('shares concurrent acquisition then caches the same current token', async () => {
|
|
59
|
+
let release: ((value: Response) => void) | undefined;
|
|
60
|
+
const transport = jest.fn(
|
|
61
|
+
() =>
|
|
62
|
+
new Promise<Response>((resolve) => {
|
|
63
|
+
release = resolve;
|
|
64
|
+
}),
|
|
65
|
+
);
|
|
66
|
+
const client = create({ transport });
|
|
67
|
+
const results = Array.from({ length: 20 }, () => client.getAccessToken());
|
|
68
|
+
expect(transport).toHaveBeenCalledTimes(1);
|
|
69
|
+
if (!release) throw new Error('Missing grant');
|
|
70
|
+
release(response());
|
|
71
|
+
expect(await Promise.all(results)).toEqual(
|
|
72
|
+
Array.from({ length: 20 }, () => 'PRIVATE_TOKEN'),
|
|
73
|
+
);
|
|
74
|
+
expect(await client.getAccessToken()).toBe('PRIVATE_TOKEN');
|
|
75
|
+
expect(transport).toHaveBeenCalledTimes(1);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('renews at request-start expiry with a conservative lifetime margin', async () => {
|
|
79
|
+
let now = 1000;
|
|
80
|
+
let count = 0;
|
|
81
|
+
const transport = jest.fn(() =>
|
|
82
|
+
Promise.resolve(
|
|
83
|
+
response({ access_token: `token${++count}`, expires_in: 10 }),
|
|
84
|
+
),
|
|
85
|
+
);
|
|
86
|
+
const client = create({ clock: () => now, transport });
|
|
87
|
+
expect(await client.getAccessToken()).toBe('token1');
|
|
88
|
+
now = 9999;
|
|
89
|
+
expect(await client.getAccessToken()).toBe('token1');
|
|
90
|
+
now = 10000;
|
|
91
|
+
expect(await client.getAccessToken()).toBe('token2');
|
|
92
|
+
expect(transport).toHaveBeenCalledTimes(2);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it('compare-token invalidation preserves a newer cache and never resends anything', async () => {
|
|
96
|
+
let count = 0;
|
|
97
|
+
const transport = jest.fn(() =>
|
|
98
|
+
Promise.resolve(response({ access_token: `token${++count}` })),
|
|
99
|
+
);
|
|
100
|
+
const client = create({ transport });
|
|
101
|
+
const old = await client.getAccessToken();
|
|
102
|
+
client.invalidate(old);
|
|
103
|
+
const current = await client.getAccessToken();
|
|
104
|
+
client.invalidate(old);
|
|
105
|
+
expect(await client.getAccessToken()).toBe(current);
|
|
106
|
+
expect(transport).toHaveBeenCalledTimes(2);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it('clears failed single-flight state, rejects stale cache and does not implicitly retry', async () => {
|
|
110
|
+
let now = 1000;
|
|
111
|
+
const transport = jest
|
|
112
|
+
.fn()
|
|
113
|
+
.mockImplementationOnce(() =>
|
|
114
|
+
Promise.resolve(response({ expires_in: 1 })),
|
|
115
|
+
)
|
|
116
|
+
.mockImplementationOnce(() =>
|
|
117
|
+
Promise.resolve(new Response('PRIVATE_RESPONSE', { status: 503 })),
|
|
118
|
+
)
|
|
119
|
+
.mockImplementationOnce(() => Promise.resolve(response()));
|
|
120
|
+
const client = create({ transport, clock: () => now });
|
|
121
|
+
await client.getAccessToken();
|
|
122
|
+
now = 2000;
|
|
123
|
+
const error = await client
|
|
124
|
+
.getAccessToken()
|
|
125
|
+
.catch((value: unknown) => value);
|
|
126
|
+
expect(error).toBeInstanceOf(OAuth2ClientCredentialsUnavailableError);
|
|
127
|
+
expect(error).toMatchObject({
|
|
128
|
+
message: 'Provider credentials unavailable',
|
|
129
|
+
reason: 'http',
|
|
130
|
+
status: 503,
|
|
131
|
+
});
|
|
132
|
+
expect(String(error)).not.toContain('PRIVATE');
|
|
133
|
+
expect(transport).toHaveBeenCalledTimes(2);
|
|
134
|
+
expect(await client.getAccessToken()).toBe('PRIVATE_TOKEN');
|
|
135
|
+
expect(transport).toHaveBeenCalledTimes(3);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it('requires explicit decimal-string expiry compatibility', async () => {
|
|
139
|
+
const transport = () => Promise.resolve(response({ expires_in: '3600' }));
|
|
140
|
+
await expect(create({ transport }).getAccessToken()).rejects.toMatchObject({
|
|
141
|
+
reason: 'invalid_response',
|
|
142
|
+
});
|
|
143
|
+
expect(
|
|
144
|
+
await create({ transport, allowStringExpiresIn: true }).getAccessToken(),
|
|
145
|
+
).toBe('PRIVATE_TOKEN');
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it.each([
|
|
149
|
+
{ access_token: '' },
|
|
150
|
+
{ access_token: 'PRIVATE\nTOKEN' },
|
|
151
|
+
{ access_token: 'x'.repeat(17000) },
|
|
152
|
+
{ token_type: 'MAC' },
|
|
153
|
+
{ expires_in: undefined },
|
|
154
|
+
{ expires_in: 0 },
|
|
155
|
+
{ expires_in: -1 },
|
|
156
|
+
{ expires_in: 90000 },
|
|
157
|
+
{ expires_in: 1.5 },
|
|
158
|
+
{ expires_in: '3600.0' },
|
|
159
|
+
])('rejects invalid provider token contracts safely', async (changes) => {
|
|
160
|
+
await expect(
|
|
161
|
+
create({
|
|
162
|
+
transport: () => Promise.resolve(response(changes)),
|
|
163
|
+
}).getAccessToken(),
|
|
164
|
+
).rejects.toMatchObject({
|
|
165
|
+
message: 'Provider credentials unavailable',
|
|
166
|
+
reason: 'invalid_response',
|
|
167
|
+
});
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it('rejects tokens which expired during acquisition and invalid clock evidence', async () => {
|
|
171
|
+
let now = 1000;
|
|
172
|
+
await expect(
|
|
173
|
+
create({
|
|
174
|
+
clock: () => now,
|
|
175
|
+
transport: () => {
|
|
176
|
+
now = 5000;
|
|
177
|
+
return Promise.resolve(response({ expires_in: 1 }));
|
|
178
|
+
},
|
|
179
|
+
}).getAccessToken(),
|
|
180
|
+
).rejects.toMatchObject({ reason: 'invalid_response' });
|
|
181
|
+
await expect(
|
|
182
|
+
create({ clock: () => NaN }).getAccessToken(),
|
|
183
|
+
).rejects.toMatchObject({ reason: 'invalid_response' });
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it.each([
|
|
187
|
+
{ tokenUrl: 'http://provider.example.test/token' },
|
|
188
|
+
{ tokenUrl: 'https://PRIVATE@provider.example.test/token' },
|
|
189
|
+
{ tokenUrl: 'https://provider.example.test/token?PRIVATE' },
|
|
190
|
+
{ tokenUrl: 'https://provider.example.test/' },
|
|
191
|
+
{ tokenUrl: 'https://provider.example.test/%2E%2E/token' },
|
|
192
|
+
{ clientId: '' },
|
|
193
|
+
{ clientSecret: '' },
|
|
194
|
+
{ scopes: ['scope with space'] },
|
|
195
|
+
{ scopes: ['x', 'x'] },
|
|
196
|
+
{ renewalMarginMs: 60001 },
|
|
197
|
+
])('rejects unsafe configuration without exposing values', (options) => {
|
|
198
|
+
expect(() => create(options)).toThrow(
|
|
199
|
+
'Invalid provider credential configuration',
|
|
200
|
+
);
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
it('bounds grant network/stream timeouts and sanitizes unexpected transport failures', async () => {
|
|
204
|
+
await expect(
|
|
205
|
+
create({
|
|
206
|
+
timeoutMs: 10,
|
|
207
|
+
transport: () => new Promise<Response>(() => undefined),
|
|
208
|
+
}).getAccessToken(),
|
|
209
|
+
).rejects.toMatchObject({ reason: 'timeout' });
|
|
210
|
+
await expect(
|
|
211
|
+
create({
|
|
212
|
+
transport: () => Promise.reject(new Error('PRIVATE_CREDENTIALS')),
|
|
213
|
+
}).getAccessToken(),
|
|
214
|
+
).rejects.toMatchObject({
|
|
215
|
+
message: 'Provider credentials unavailable',
|
|
216
|
+
reason: 'network',
|
|
217
|
+
});
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
it('exports one owning sync/async Nest provider without app wiring', () => {
|
|
221
|
+
const options = {
|
|
222
|
+
tokenUrl: 'https://provider.example.test/token',
|
|
223
|
+
clientId: 'id',
|
|
224
|
+
clientSecret: 'secret',
|
|
225
|
+
};
|
|
226
|
+
expect(OAuth2ClientCredentialsModule.register(options).exports).toEqual([
|
|
227
|
+
OAuth2ClientCredentialsClient,
|
|
228
|
+
]);
|
|
229
|
+
expect(
|
|
230
|
+
OAuth2ClientCredentialsModule.registerAsync({ useFactory: () => options })
|
|
231
|
+
.exports,
|
|
232
|
+
).toEqual([OAuth2ClientCredentialsClient]);
|
|
233
|
+
});
|
|
234
|
+
});
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
import {
|
|
2
|
+
BoundedJsonHttpClient,
|
|
3
|
+
BoundedJsonHttpUnavailableError,
|
|
4
|
+
} from '../oauth2-token-introspection/bounded-json-http.client';
|
|
5
|
+
|
|
6
|
+
export const OAUTH2_CLIENT_CREDENTIALS_OPTIONS = Symbol(
|
|
7
|
+
'OAUTH2_CLIENT_CREDENTIALS_OPTIONS',
|
|
8
|
+
);
|
|
9
|
+
|
|
10
|
+
export interface OAuth2ClientCredentialsOptions {
|
|
11
|
+
tokenUrl: string;
|
|
12
|
+
clientId: string;
|
|
13
|
+
clientSecret: string;
|
|
14
|
+
scopes?: readonly string[];
|
|
15
|
+
timeoutMs?: number;
|
|
16
|
+
maxResponseBytes?: number;
|
|
17
|
+
maxTokenBytes?: number;
|
|
18
|
+
renewalMarginMs?: number;
|
|
19
|
+
/** Explicit compatibility for providers documenting decimal-string lifetimes. */
|
|
20
|
+
allowStringExpiresIn?: boolean;
|
|
21
|
+
transport?: (url: string, init: RequestInit) => Promise<Response>;
|
|
22
|
+
clock?: () => number;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export class OAuth2ClientCredentialsUnavailableError extends Error {
|
|
26
|
+
constructor(
|
|
27
|
+
readonly reason: 'timeout' | 'network' | 'http' | 'invalid_response',
|
|
28
|
+
readonly status?: number,
|
|
29
|
+
) {
|
|
30
|
+
super('Provider credentials unavailable');
|
|
31
|
+
this.name = 'OAuth2ClientCredentialsUnavailableError';
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function bounded(
|
|
36
|
+
value: number | undefined,
|
|
37
|
+
fallback: number,
|
|
38
|
+
min: number,
|
|
39
|
+
max: number,
|
|
40
|
+
): number {
|
|
41
|
+
const result = value ?? fallback;
|
|
42
|
+
if (!Number.isSafeInteger(result) || result < min || result > max)
|
|
43
|
+
throw new Error();
|
|
44
|
+
return result;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function formComponent(value: string): string {
|
|
48
|
+
return new URLSearchParams({ v: value }).toString().slice(2);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function isScopeList(value: unknown): value is readonly string[] {
|
|
52
|
+
return (
|
|
53
|
+
Array.isArray(value) &&
|
|
54
|
+
value.length <= 32 &&
|
|
55
|
+
value.every(
|
|
56
|
+
(scope: unknown) =>
|
|
57
|
+
typeof scope === 'string' &&
|
|
58
|
+
scope.length > 0 &&
|
|
59
|
+
scope.length <= 200 &&
|
|
60
|
+
![...scope].some(
|
|
61
|
+
(c) =>
|
|
62
|
+
c.charCodeAt(0) < 33 ||
|
|
63
|
+
c.charCodeAt(0) > 126 ||
|
|
64
|
+
c === '"' ||
|
|
65
|
+
c === '\\',
|
|
66
|
+
),
|
|
67
|
+
)
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Grant acquisition only. Consumers own authorization and provider side effects. */
|
|
72
|
+
export class OAuth2ClientCredentialsClient {
|
|
73
|
+
readonly #http: BoundedJsonHttpClient;
|
|
74
|
+
readonly #segments: readonly string[];
|
|
75
|
+
readonly #form: Readonly<Record<string, string>>;
|
|
76
|
+
readonly #clock: () => number;
|
|
77
|
+
readonly #margin: number;
|
|
78
|
+
readonly #maxTokenBytes: number;
|
|
79
|
+
readonly #allowStringExpiry: boolean;
|
|
80
|
+
#cached?: { token: string; refreshAt: number };
|
|
81
|
+
#inFlight?: Promise<string>;
|
|
82
|
+
|
|
83
|
+
constructor(options: OAuth2ClientCredentialsOptions) {
|
|
84
|
+
try {
|
|
85
|
+
const url = new URL(options.tokenUrl);
|
|
86
|
+
if (
|
|
87
|
+
url.href !== options.tokenUrl ||
|
|
88
|
+
url.href.length > 8192 ||
|
|
89
|
+
url.protocol !== 'https:' ||
|
|
90
|
+
url.username ||
|
|
91
|
+
url.password ||
|
|
92
|
+
url.hash ||
|
|
93
|
+
url.search ||
|
|
94
|
+
url.pathname === '/'
|
|
95
|
+
)
|
|
96
|
+
throw new Error();
|
|
97
|
+
for (const value of [options.clientId, options.clientSecret]) {
|
|
98
|
+
if (typeof value !== 'string' || !value.length || value.length > 4096)
|
|
99
|
+
throw new Error();
|
|
100
|
+
}
|
|
101
|
+
const scopes = options.scopes ?? [];
|
|
102
|
+
if (!isScopeList(scopes) || new Set(scopes).size !== scopes.length)
|
|
103
|
+
throw new Error();
|
|
104
|
+
if (
|
|
105
|
+
options.allowStringExpiresIn !== undefined &&
|
|
106
|
+
typeof options.allowStringExpiresIn !== 'boolean'
|
|
107
|
+
)
|
|
108
|
+
throw new Error();
|
|
109
|
+
this.#segments = Object.freeze(
|
|
110
|
+
url.pathname
|
|
111
|
+
.slice(1)
|
|
112
|
+
.split('/')
|
|
113
|
+
.map((segment) => decodeURIComponent(segment)),
|
|
114
|
+
);
|
|
115
|
+
if (
|
|
116
|
+
this.#segments.some(
|
|
117
|
+
(segment) => !segment || ['.', '..'].includes(segment),
|
|
118
|
+
)
|
|
119
|
+
)
|
|
120
|
+
throw new Error();
|
|
121
|
+
this.#http = new BoundedJsonHttpClient({
|
|
122
|
+
origin: url.origin,
|
|
123
|
+
requestHeaders: {
|
|
124
|
+
authorization: `Basic ${Buffer.from(`${formComponent(options.clientId)}:${formComponent(options.clientSecret)}`).toString('base64')}`,
|
|
125
|
+
},
|
|
126
|
+
timeoutMs: options.timeoutMs,
|
|
127
|
+
maxResponseBytes: options.maxResponseBytes,
|
|
128
|
+
transport: options.transport,
|
|
129
|
+
});
|
|
130
|
+
this.#form = Object.freeze({
|
|
131
|
+
grant_type: 'client_credentials',
|
|
132
|
+
...(scopes.length ? { scope: scopes.join(' ') } : {}),
|
|
133
|
+
});
|
|
134
|
+
this.#clock = options.clock ?? (() => Math.floor(performance.now()));
|
|
135
|
+
if (typeof this.#clock !== 'function') throw new Error();
|
|
136
|
+
this.#margin = bounded(options.renewalMarginMs, 30000, 0, 60000);
|
|
137
|
+
this.#maxTokenBytes = bounded(options.maxTokenBytes, 16384, 1, 65536);
|
|
138
|
+
this.#allowStringExpiry = options.allowStringExpiresIn === true;
|
|
139
|
+
} catch {
|
|
140
|
+
throw new Error('Invalid provider credential configuration');
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
getAccessToken(): Promise<string> {
|
|
145
|
+
const now = this.#clock();
|
|
146
|
+
if (!Number.isSafeInteger(now) || now < 0)
|
|
147
|
+
return Promise.reject(
|
|
148
|
+
new OAuth2ClientCredentialsUnavailableError('invalid_response'),
|
|
149
|
+
);
|
|
150
|
+
if (this.#cached && now < this.#cached.refreshAt)
|
|
151
|
+
return Promise.resolve(this.#cached.token);
|
|
152
|
+
this.#cached = undefined;
|
|
153
|
+
this.#inFlight ??= this.acquire(now).finally(() => {
|
|
154
|
+
this.#inFlight = undefined;
|
|
155
|
+
});
|
|
156
|
+
return this.#inFlight;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** A late rejection of token A must not evict a newer token B. No retry occurs. */
|
|
160
|
+
invalidate(rejectedToken: string): void {
|
|
161
|
+
if (this.#cached?.token === rejectedToken) this.#cached = undefined;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
private async acquire(startedAt: number): Promise<string> {
|
|
165
|
+
try {
|
|
166
|
+
const result = await this.#http.postForm(this.#segments, this.#form);
|
|
167
|
+
if (!result || typeof result !== 'object' || Array.isArray(result))
|
|
168
|
+
throw new OAuth2ClientCredentialsUnavailableError('invalid_response');
|
|
169
|
+
const body = result as Record<string, unknown>;
|
|
170
|
+
const token = body['access_token'];
|
|
171
|
+
const tokenType = body['token_type'];
|
|
172
|
+
const lifetime = body['expires_in'];
|
|
173
|
+
const seconds =
|
|
174
|
+
this.#allowStringExpiry &&
|
|
175
|
+
typeof lifetime === 'string' &&
|
|
176
|
+
/^[1-9][0-9]{0,4}$/.test(lifetime)
|
|
177
|
+
? Number(lifetime)
|
|
178
|
+
: lifetime;
|
|
179
|
+
if (
|
|
180
|
+
typeof token !== 'string' ||
|
|
181
|
+
!token.length ||
|
|
182
|
+
new TextEncoder().encode(token).byteLength > this.#maxTokenBytes ||
|
|
183
|
+
[...token].some((c) => c.charCodeAt(0) < 33 || c.charCodeAt(0) > 126) ||
|
|
184
|
+
typeof tokenType !== 'string' ||
|
|
185
|
+
tokenType.toLowerCase() !== 'bearer' ||
|
|
186
|
+
typeof seconds !== 'number' ||
|
|
187
|
+
!Number.isSafeInteger(seconds) ||
|
|
188
|
+
seconds < 1 ||
|
|
189
|
+
seconds > 86400
|
|
190
|
+
)
|
|
191
|
+
throw new OAuth2ClientCredentialsUnavailableError('invalid_response');
|
|
192
|
+
const ttl = seconds * 1000;
|
|
193
|
+
const refreshAt =
|
|
194
|
+
startedAt + ttl - Math.min(this.#margin, Math.floor(ttl / 10));
|
|
195
|
+
const now = this.#clock();
|
|
196
|
+
if (!Number.isSafeInteger(now) || now < startedAt || now >= refreshAt)
|
|
197
|
+
throw new OAuth2ClientCredentialsUnavailableError('invalid_response');
|
|
198
|
+
this.#cached = { token, refreshAt };
|
|
199
|
+
return token;
|
|
200
|
+
} catch (error) {
|
|
201
|
+
if (error instanceof OAuth2ClientCredentialsUnavailableError) throw error;
|
|
202
|
+
if (error instanceof BoundedJsonHttpUnavailableError)
|
|
203
|
+
throw new OAuth2ClientCredentialsUnavailableError(
|
|
204
|
+
error.reason,
|
|
205
|
+
error.status,
|
|
206
|
+
);
|
|
207
|
+
throw new OAuth2ClientCredentialsUnavailableError('network');
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import {
|
|
2
|
+
DynamicModule,
|
|
3
|
+
FactoryProvider,
|
|
4
|
+
Module,
|
|
5
|
+
ModuleMetadata,
|
|
6
|
+
} from '@nestjs/common';
|
|
7
|
+
import {
|
|
8
|
+
OAUTH2_CLIENT_CREDENTIALS_OPTIONS,
|
|
9
|
+
OAuth2ClientCredentialsClient,
|
|
10
|
+
OAuth2ClientCredentialsOptions,
|
|
11
|
+
} from './oauth2-client-credentials.client';
|
|
12
|
+
|
|
13
|
+
export interface OAuth2ClientCredentialsModuleAsyncOptions extends Pick<
|
|
14
|
+
ModuleMetadata,
|
|
15
|
+
'imports'
|
|
16
|
+
> {
|
|
17
|
+
inject?: FactoryProvider<OAuth2ClientCredentialsOptions>['inject'];
|
|
18
|
+
useFactory: FactoryProvider<OAuth2ClientCredentialsOptions>['useFactory'];
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const clientProvider: FactoryProvider<OAuth2ClientCredentialsClient> = {
|
|
22
|
+
provide: OAuth2ClientCredentialsClient,
|
|
23
|
+
inject: [OAUTH2_CLIENT_CREDENTIALS_OPTIONS],
|
|
24
|
+
useFactory: (options: OAuth2ClientCredentialsOptions) =>
|
|
25
|
+
new OAuth2ClientCredentialsClient(options),
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
@Module({})
|
|
29
|
+
export class OAuth2ClientCredentialsModule {
|
|
30
|
+
static register(options: OAuth2ClientCredentialsOptions): DynamicModule {
|
|
31
|
+
return {
|
|
32
|
+
module: OAuth2ClientCredentialsModule,
|
|
33
|
+
providers: [
|
|
34
|
+
{ provide: OAUTH2_CLIENT_CREDENTIALS_OPTIONS, useValue: options },
|
|
35
|
+
clientProvider,
|
|
36
|
+
],
|
|
37
|
+
exports: [OAuth2ClientCredentialsClient],
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
static registerAsync(
|
|
42
|
+
options: OAuth2ClientCredentialsModuleAsyncOptions,
|
|
43
|
+
): DynamicModule {
|
|
44
|
+
return {
|
|
45
|
+
module: OAuth2ClientCredentialsModule,
|
|
46
|
+
imports: options.imports ?? [],
|
|
47
|
+
providers: [
|
|
48
|
+
{
|
|
49
|
+
provide: OAUTH2_CLIENT_CREDENTIALS_OPTIONS,
|
|
50
|
+
inject: options.inject ?? [],
|
|
51
|
+
useFactory: options.useFactory,
|
|
52
|
+
},
|
|
53
|
+
clientProvider,
|
|
54
|
+
],
|
|
55
|
+
exports: [OAuth2ClientCredentialsClient],
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
}
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
# OAuth2 token introspection
|
|
2
|
+
|
|
3
|
+
```sh
|
|
4
|
+
ddd recipe oauth2-token-introspection --path <service> --dry-run
|
|
5
|
+
ddd recipe oauth2-token-introspection --path <service>
|
|
6
|
+
```
|
|
7
|
+
|
|
8
|
+
The recipe adds a dependency-free RFC7662 client, sync/async Nest module, barrel,
|
|
9
|
+
consumer tests and this document. It does not wire AppModule, install packages,
|
|
10
|
+
change lockfiles or grant access. Dry-run writes nothing. All targets are checked
|
|
11
|
+
before writes; differing files and symlinks fail closed, identical reruns are
|
|
12
|
+
untouched, and failed writes roll back unchanged files created by this invocation.
|
|
13
|
+
Rollback checks inode, timestamps and content; concurrent owner changes are
|
|
14
|
+
preserved and reported as an explicit rollback conflict.
|
|
15
|
+
|
|
16
|
+
## Configuration and composition
|
|
17
|
+
|
|
18
|
+
Import the owning module; do not redeclare its exported client in a consumer.
|
|
19
|
+
|
|
20
|
+
```ts
|
|
21
|
+
OAuth2TokenIntrospectionModule.registerAsync({
|
|
22
|
+
imports: [ConfigModule],
|
|
23
|
+
inject: [ConfigService],
|
|
24
|
+
useFactory: (config: ConfigService): OAuth2TokenIntrospectionOptions => ({
|
|
25
|
+
introspectionUrl: config.getOrThrow<string>('OAUTH2_INTROSPECTION_URL'),
|
|
26
|
+
requestHeaders: { authorization: config.getOrThrow<string>('OAUTH2_INTROSPECTION_AUTHORIZATION') },
|
|
27
|
+
timeoutMs: 3000,
|
|
28
|
+
}),
|
|
29
|
+
})
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
Set the exact trusted URL and required provider authentication headers from
|
|
33
|
+
validated deployment configuration. Never derive the URL or headers from a
|
|
34
|
+
request. HTTPS is the default. Credentials, queries and fragments in the URL
|
|
35
|
+
are rejected; redirects are disabled. For a verified private HTTP endpoint,
|
|
36
|
+
explicitly set `trustedInternalHttp: true`. For example, self-hosted Hydra v26.2.0
|
|
37
|
+
can use `http://hydra-admin.ory-staging.svc.cluster.local:4445/admin/oauth2/introspect`.
|
|
38
|
+
This opt-in does not add TLS or secure a cluster: the operator must establish
|
|
39
|
+
private routing and network policy and keep admin ports off public ingress.
|
|
40
|
+
Hydra's admin listener uses private network protection; do not assume a Basic
|
|
41
|
+
header authenticates it. Omit `requestHeaders` when the selected server does not
|
|
42
|
+
require them. No introspection endpoint is discovered from token contents.
|
|
43
|
+
|
|
44
|
+
Options snapshot URL/headers and bound timeout (10..30000 ms, default 3000),
|
|
45
|
+
response bytes (128..1048576, default 65536) and token bytes (1..65536, default
|
|
46
|
+
16384). Tokens must be nonempty visible ASCII without a Bearer prefix. A timeout
|
|
47
|
+
includes transport and response streaming; even a noncompliant transport cannot
|
|
48
|
+
hold the caller indefinitely. `transport(url, RequestInit)` is an injectable
|
|
49
|
+
Fetch-compatible seam for tests or an owned transport; it must honor the URL,
|
|
50
|
+
AbortSignal and redirect policy. No retries or positive-validation cache exist.
|
|
51
|
+
Every call rechecks the provider. Runtime baseline is Node 18+ or Bun with Fetch
|
|
52
|
+
and Web Streams; no consumer dependency installation is needed.
|
|
53
|
+
|
|
54
|
+
## Bounded provider JSON GET
|
|
55
|
+
|
|
56
|
+
The barrel also exports `BoundedJsonHttpClient`, its options and
|
|
57
|
+
`BoundedJsonHttpUnavailableError`. Introspection delegates to this same mechanism;
|
|
58
|
+
provider adapters do not need to copy timeout, stream limits or HTTP error logic.
|
|
59
|
+
Each instance has an exact trusted origin from validated deployment config, with
|
|
60
|
+
no path, query, fragment or credentials. Its GET API accepts path segments and a
|
|
61
|
+
bounded query record; it percent-encodes segments and rejects dot traversal.
|
|
62
|
+
No call can select another origin. No bearer token or body is sent on GET.
|
|
63
|
+
|
|
64
|
+
```ts
|
|
65
|
+
const kratos = new BoundedJsonHttpClient({
|
|
66
|
+
origin: config.getOrThrow<string>('KRATOS_ADMIN_ORIGIN'),
|
|
67
|
+
trustedInternalHttp: true, // only for the verified private cluster endpoint
|
|
68
|
+
timeoutMs: 3000,
|
|
69
|
+
});
|
|
70
|
+
// The owning adapter validates sessionId/identityId as UUIDs before these calls.
|
|
71
|
+
const sessionJson = await kratos.get(['admin', 'sessions', sessionId], { expand: 'identity' });
|
|
72
|
+
const identityJson = await kratos.get(['admin', 'identities', identityId]);
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
Use only configured origin and fixed resource route segments, followed by encoded
|
|
76
|
+
identifiers validated by the consumer. Never pass an origin from request data.
|
|
77
|
+
The return type is unknown JSON; the consumer validates provider schema, active
|
|
78
|
+
session/identity, expiry, assurance, binding and authorization. The mechanism
|
|
79
|
+
does not infer account status from JSON. Transport errors retain safe reason and
|
|
80
|
+
optional numeric HTTP status, so the adapter can distinguish a missing session
|
|
81
|
+
from upstream unavailability without inspecting or logging response bodies.
|
|
82
|
+
The helper also exposes bounded `postForm` for the introspection client and
|
|
83
|
+
`postJson`/`patchJson`/`putJson` for explicit consumer-owned JSON endpoints. JSON is
|
|
84
|
+
snapshotted before dispatch, validated against the same byte/depth/value bounds,
|
|
85
|
+
and sent once to the configured origin with no redirects or retries. It
|
|
86
|
+
applies the same origin, redirect, timeout, byte, nesting and no-cache rules.
|
|
87
|
+
|
|
88
|
+
`putJson(pathSegments, payload, query = {})` also accepts the same bounded query
|
|
89
|
+
record as GET. For example, Hydra login/consent accept and reject APIs use a
|
|
90
|
+
JSON PUT body with the challenge in a query parameter:
|
|
91
|
+
|
|
92
|
+
```ts
|
|
93
|
+
const result = await hydra.putJson(
|
|
94
|
+
['admin', 'oauth2', 'auth', 'requests', 'login', 'accept'],
|
|
95
|
+
{ subject: validatedKratosIdentityId, remember: false },
|
|
96
|
+
{ login_challenge: validatedLoginChallenge },
|
|
97
|
+
);
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
The adapter must first verify the challenge, exact client/scopes/audience,
|
|
101
|
+
current Kratos session and canonical admission policy. A successful transport
|
|
102
|
+
request establishes none of those facts. Challenge query values are snapshotted
|
|
103
|
+
and encoded before dispatch; failed or uncertain decisions are not retried.
|
|
104
|
+
|
|
105
|
+
## Evidence and consumer policy
|
|
106
|
+
|
|
107
|
+
`introspect(originalAccessToken)` POSTs form-encoded `token` and
|
|
108
|
+
`token_type_hint=access_token`. The hint does not prove token kind. Active false
|
|
109
|
+
throws `InvalidOAuth2TokenError`; active true returns immutable evidence with
|
|
110
|
+
optional issuer, subject, client ID, numeric dates, token type/use, audience and
|
|
111
|
+
scope arrays, raw claims, and provider extensions. RFC7662 permits missing
|
|
112
|
+
claims; the client does not invent them or map them into people or permissions.
|
|
113
|
+
|
|
114
|
+
Prefer `validate(token, policy)` to return only a consumer-approved principal:
|
|
115
|
+
|
|
116
|
+
```ts
|
|
117
|
+
const principal = await client.validate(token, {
|
|
118
|
+
validate: async (evidence) => {
|
|
119
|
+
// The owner must enforce the selected provider's required issuer, resource
|
|
120
|
+
// audience, authorized client, time, access-token profile and actor contract.
|
|
121
|
+
// Then check canonical identity, current session/revocation and domain scope.
|
|
122
|
+
if (evidence.issuer !== trustedIssuer || evidence.tokenUse !== 'access_token') {
|
|
123
|
+
throw new InvalidOAuth2TokenError('policy_rejected');
|
|
124
|
+
}
|
|
125
|
+
return owningAdmissionPolicy.resolve(evidence);
|
|
126
|
+
},
|
|
127
|
+
});
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
The example delegates the remaining checks; matching issuer and token_use alone
|
|
131
|
+
is insufficient. No generic grant-type or human/machine inference is made.
|
|
132
|
+
ZITADEL JWT validation remains a separate supported recipe; this transport must
|
|
133
|
+
not become an automatic fallback after a rejected token. For Hydra v26.2.0,
|
|
134
|
+
introspection returns `client_id`, `token_use`, `aud` and `ext`; arbitrary
|
|
135
|
+
consent `session.access_token` data is returned in `ext` and carried through
|
|
136
|
+
refresh grants. A Kratos binding in that data is a consumer-defined convention,
|
|
137
|
+
not a built-in introspection session field. Hydra and Kratos session revocation
|
|
138
|
+
are separate; the owner must validate any bound Kratos session independently.
|
|
139
|
+
|
|
140
|
+
## Failures and proof
|
|
141
|
+
|
|
142
|
+
`InvalidOAuth2TokenError` means malformed, inactive or explicitly policy-rejected
|
|
143
|
+
credentials; map to the consumer's denial response. `OAuth2IntrospectionUnavailableError`
|
|
144
|
+
means timeout, transport, HTTP, malformed upstream response or unexpected policy
|
|
145
|
+
failure; deny access while distinguishing an unavailable validator operationally.
|
|
146
|
+
Errors contain fixed messages and safe reason enums, never tokens, headers,
|
|
147
|
+
claims, URLs or upstream bodies. The client performs no logging. Explicit policy
|
|
148
|
+
errors preserve these safe classes; unexpected exceptions are sanitized.
|
|
149
|
+
Policy implementations own their own I/O limits and must not log credentials.
|
|
150
|
+
|
|
151
|
+
The generated spec verifies wire format, immutable evidence, no validation
|
|
152
|
+
cache, denial, HTTP opt-in, malformed/bounded responses, hangs, error redaction,
|
|
153
|
+
consumer policy and module ownership. It proves a transport contract, not
|
|
154
|
+
deployment, provider authentication, clinical permissions or real actor access.
|
|
155
|
+
|
|
156
|
+
Sources: [RFC7662](https://www.rfc-editor.org/rfc/rfc7662),
|
|
157
|
+
[pinned Hydra API](https://raw.githubusercontent.com/ory/hydra/v26.2.0/spec/api.json),
|
|
158
|
+
[pinned Hydra admin server](https://raw.githubusercontent.com/ory/hydra/v26.2.0/cmd/server/handler.go).
|