nestjs-ddd-cli 3.2.2 → 3.3.1
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,663 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test';
|
|
2
|
+
import { createHash } from 'node:crypto';
|
|
3
|
+
import { exportJWK, generateKeyPair, SignJWT, type JWTPayload } from 'jose';
|
|
4
|
+
import {
|
|
5
|
+
createOidcBffClient,
|
|
6
|
+
OidcInvalidGrantError,
|
|
7
|
+
OidcUnavailableError,
|
|
8
|
+
type OidcBffConfig,
|
|
9
|
+
type PendingAuthorization,
|
|
10
|
+
} from './oidc-bff.client';
|
|
11
|
+
const ISSUER = 'https://identity.example.test/';
|
|
12
|
+
|
|
13
|
+
const nowMs = 1_797_000_000_000;
|
|
14
|
+
const at = nowMs / 1000;
|
|
15
|
+
const pending: PendingAuthorization = Object.freeze({
|
|
16
|
+
state: 's'.repeat(43),
|
|
17
|
+
nonce: 'n'.repeat(43),
|
|
18
|
+
codeVerifier: 'v'.repeat(43),
|
|
19
|
+
startedAtMs: nowMs - 1000,
|
|
20
|
+
requireFreshAuth: true,
|
|
21
|
+
});
|
|
22
|
+
const config: OidcBffConfig = Object.freeze({
|
|
23
|
+
issuer: ISSUER,
|
|
24
|
+
clientId: 'web-client',
|
|
25
|
+
clientSecret: 'synthetic-client-secret',
|
|
26
|
+
redirectUri: 'https://app.example.test/callback',
|
|
27
|
+
scopes: ['openid', 'offline_access', 'application:read'],
|
|
28
|
+
endpoints: {
|
|
29
|
+
discovery: `${ISSUER}.well-known/openid-configuration`,
|
|
30
|
+
authorization: `${ISSUER}oauth2/auth`,
|
|
31
|
+
token: `${ISSUER}oauth2/token`,
|
|
32
|
+
revocation: `${ISSUER}oauth2/revoke`,
|
|
33
|
+
jwks: `${ISSUER}.well-known/jwks.json`,
|
|
34
|
+
},
|
|
35
|
+
authorizationParameters: { audience: 'https://api.example.test' },
|
|
36
|
+
});
|
|
37
|
+
const keyPair = await generateKeyPair('RS256', { modulusLength: 2048, extractable: true });
|
|
38
|
+
const publicKey = {
|
|
39
|
+
...(await exportJWK(keyPair.publicKey)),
|
|
40
|
+
kid: 'synthetic-signing-key',
|
|
41
|
+
alg: 'RS256',
|
|
42
|
+
use: 'sig',
|
|
43
|
+
};
|
|
44
|
+
const metadata = {
|
|
45
|
+
issuer: ISSUER,
|
|
46
|
+
authorization_endpoint: `${ISSUER}oauth2/auth`,
|
|
47
|
+
token_endpoint: `${ISSUER}oauth2/token`,
|
|
48
|
+
revocation_endpoint: `${ISSUER}oauth2/revoke`,
|
|
49
|
+
jwks_uri: `${ISSUER}.well-known/jwks.json`,
|
|
50
|
+
response_types_supported: ['code'],
|
|
51
|
+
id_token_signing_alg_values_supported: ['RS256'],
|
|
52
|
+
token_endpoint_auth_methods_supported: ['client_secret_basic'],
|
|
53
|
+
code_challenge_methods_supported: ['S256'],
|
|
54
|
+
};
|
|
55
|
+
const signedClaims = {
|
|
56
|
+
iss: ISSUER,
|
|
57
|
+
sub: 'synthetic-identity',
|
|
58
|
+
aud: config.clientId,
|
|
59
|
+
exp: at + 300,
|
|
60
|
+
iat: at,
|
|
61
|
+
auth_time: at,
|
|
62
|
+
nonce: pending.nonce,
|
|
63
|
+
};
|
|
64
|
+
const exchange = { code: 'synthetic-code', state: pending.state, pending };
|
|
65
|
+
const json = (value: unknown, status = 200) => Response.json(value, { status });
|
|
66
|
+
async function signed(
|
|
67
|
+
claims: JWTPayload = {},
|
|
68
|
+
header: { alg: string; kid?: string } = { alg: 'RS256', kid: publicKey.kid },
|
|
69
|
+
) {
|
|
70
|
+
return new SignJWT({ ...signedClaims, ...claims })
|
|
71
|
+
.setProtectedHeader(header)
|
|
72
|
+
.sign(keyPair.privateKey);
|
|
73
|
+
}
|
|
74
|
+
type Call = { url: string; init: RequestInit };
|
|
75
|
+
async function fixture(
|
|
76
|
+
options: {
|
|
77
|
+
claims?: JWTPayload;
|
|
78
|
+
token?: Record<string, unknown>;
|
|
79
|
+
discovery?: unknown;
|
|
80
|
+
keys?: unknown;
|
|
81
|
+
tokenResponse?: Response;
|
|
82
|
+
tokenString?: string;
|
|
83
|
+
timeoutMs?: number;
|
|
84
|
+
now?: () => number;
|
|
85
|
+
onTokenPairIssued?: (
|
|
86
|
+
pair: Readonly<{ accessToken?: string; refreshToken?: string; expiresAtMs: number }>,
|
|
87
|
+
) => Promise<void>;
|
|
88
|
+
handle?: (call: Call) => Response | Promise<Response | undefined> | undefined;
|
|
89
|
+
} = {},
|
|
90
|
+
) {
|
|
91
|
+
const idToken = options.tokenString ?? (await signed(options.claims));
|
|
92
|
+
const calls: Call[] = [];
|
|
93
|
+
const client = createOidcBffClient(
|
|
94
|
+
{ ...config, timeoutMs: options.timeoutMs },
|
|
95
|
+
{
|
|
96
|
+
now: options.now ?? (() => nowMs),
|
|
97
|
+
onTokenPairIssued: options.onTokenPairIssued,
|
|
98
|
+
fetch: async (url, init) => {
|
|
99
|
+
const call = { url, init };
|
|
100
|
+
calls.push(call);
|
|
101
|
+
const custom = await options.handle?.(call);
|
|
102
|
+
if (custom) return custom;
|
|
103
|
+
if (url.endsWith('openid-configuration')) return json(options.discovery ?? metadata);
|
|
104
|
+
if (url.endsWith('/oauth2/token'))
|
|
105
|
+
return (
|
|
106
|
+
options.tokenResponse ??
|
|
107
|
+
json({
|
|
108
|
+
access_token: 'synthetic-access-token',
|
|
109
|
+
refresh_token: 'synthetic-refresh-token',
|
|
110
|
+
id_token: idToken,
|
|
111
|
+
token_type: 'Bearer',
|
|
112
|
+
expires_in: 3600,
|
|
113
|
+
scope: config.scopes.join(' '),
|
|
114
|
+
...options.token,
|
|
115
|
+
})
|
|
116
|
+
);
|
|
117
|
+
if (url.endsWith('jwks.json')) return json(options.keys ?? { keys: [publicKey] });
|
|
118
|
+
if (url.endsWith('/oauth2/revoke')) return new Response(null, { status: 200 });
|
|
119
|
+
throw new Error('Unexpected synthetic endpoint');
|
|
120
|
+
},
|
|
121
|
+
},
|
|
122
|
+
);
|
|
123
|
+
return { client, calls, idToken };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
describe('OIDC authorization code flow', () => {
|
|
127
|
+
test('uses the existing pending record, fresh login, configured provider parameter and S256 without secrets in authorization URL', async () => {
|
|
128
|
+
const { client, calls } = await fixture();
|
|
129
|
+
const url = new URL(await client.beginAuthorization(pending));
|
|
130
|
+
expect(url.origin + url.pathname).toBe(metadata.authorization_endpoint);
|
|
131
|
+
expect(Object.fromEntries(url.searchParams)).toEqual({
|
|
132
|
+
client_id: config.clientId,
|
|
133
|
+
redirect_uri: config.redirectUri,
|
|
134
|
+
response_type: 'code',
|
|
135
|
+
scope: config.scopes.join(' '),
|
|
136
|
+
state: pending.state,
|
|
137
|
+
nonce: pending.nonce,
|
|
138
|
+
code_challenge: createHash('sha256').update(pending.codeVerifier).digest('base64url'),
|
|
139
|
+
code_challenge_method: 'S256',
|
|
140
|
+
prompt: 'login',
|
|
141
|
+
max_age: '0',
|
|
142
|
+
audience: 'https://api.example.test',
|
|
143
|
+
});
|
|
144
|
+
expect(url.searchParams.has('resource')).toBe(false);
|
|
145
|
+
expect(url.href).not.toContain(config.clientSecret);
|
|
146
|
+
expect(url.href).not.toContain(pending.codeVerifier);
|
|
147
|
+
expect(calls).toHaveLength(1);
|
|
148
|
+
expect(calls[0].init.headers).toEqual({ Accept: 'application/json' });
|
|
149
|
+
});
|
|
150
|
+
test('exchanges with exact callback and Basic client authentication, verifies real RSA signature and returns immutable evidence', async () => {
|
|
151
|
+
const { client, calls, idToken } = await fixture();
|
|
152
|
+
const tokens = await client.exchangeAuthorizationCode(exchange);
|
|
153
|
+
expect(tokens).toEqual({
|
|
154
|
+
accessToken: 'synthetic-access-token',
|
|
155
|
+
refreshToken: 'synthetic-refresh-token',
|
|
156
|
+
idToken,
|
|
157
|
+
expiresAtMs: nowMs + 3_600_000,
|
|
158
|
+
scopes: config.scopes,
|
|
159
|
+
subject: signedClaims.sub,
|
|
160
|
+
authenticatedAtSeconds: at,
|
|
161
|
+
issuer: ISSUER,
|
|
162
|
+
clientId: config.clientId,
|
|
163
|
+
});
|
|
164
|
+
expect(Object.isFrozen(tokens)).toBe(true);
|
|
165
|
+
expect(Object.isFrozen(tokens.scopes)).toBe(true);
|
|
166
|
+
const call = calls.find(({ url }) => url === metadata.token_endpoint)!;
|
|
167
|
+
expect(new Headers(call.init.headers).get('Authorization')).toBe(
|
|
168
|
+
`Basic ${Buffer.from(`${config.clientId}:${config.clientSecret}`).toString('base64')}`,
|
|
169
|
+
);
|
|
170
|
+
expect(Object.fromEntries(new URLSearchParams(String(call.init.body)))).toEqual({
|
|
171
|
+
grant_type: 'authorization_code',
|
|
172
|
+
code: exchange.code,
|
|
173
|
+
redirect_uri: config.redirectUri,
|
|
174
|
+
code_verifier: pending.codeVerifier,
|
|
175
|
+
});
|
|
176
|
+
expect(calls.every(({ init }) => init.redirect === 'error' && init.cache === 'no-store')).toBe(
|
|
177
|
+
true,
|
|
178
|
+
);
|
|
179
|
+
expect(calls.filter(({ init }) => new Headers(init.headers).has('Authorization'))).toHaveLength(
|
|
180
|
+
1,
|
|
181
|
+
);
|
|
182
|
+
});
|
|
183
|
+
test('rejects state before contacting the provider', async () => {
|
|
184
|
+
const { client, calls } = await fixture();
|
|
185
|
+
await expect(
|
|
186
|
+
client.exchangeAuthorizationCode({ ...exchange, state: 'wrong-state' }),
|
|
187
|
+
).rejects.toMatchObject({ reason: 'state' });
|
|
188
|
+
expect(calls).toHaveLength(0);
|
|
189
|
+
});
|
|
190
|
+
for (const [name, change] of Object.entries({
|
|
191
|
+
stale: { startedAtMs: nowMs - 601_000 },
|
|
192
|
+
future: { startedAtMs: nowMs + 6000 },
|
|
193
|
+
verifier: { codeVerifier: 'short' },
|
|
194
|
+
nonce: { nonce: 'short' },
|
|
195
|
+
state: { state: 'short' },
|
|
196
|
+
disabled: { requireFreshAuth: false },
|
|
197
|
+
})) {
|
|
198
|
+
test(`rejects invalid pending material: ${name}`, async () => {
|
|
199
|
+
const { client, calls } = await fixture();
|
|
200
|
+
await expect(
|
|
201
|
+
client.beginAuthorization({ ...pending, ...change } as PendingAuthorization),
|
|
202
|
+
).rejects.toMatchObject({ reason: 'pending' });
|
|
203
|
+
expect(calls).toHaveLength(0);
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
test('snapshots pending material before asynchronous provider work', async () => {
|
|
207
|
+
const mutable = { ...pending };
|
|
208
|
+
const { client } = await fixture({
|
|
209
|
+
handle: async ({ url }) => {
|
|
210
|
+
if (!url.endsWith('openid-configuration')) return undefined;
|
|
211
|
+
mutable.nonce = 'attacker-nonce';
|
|
212
|
+
mutable.codeVerifier = 'attacker-verifier';
|
|
213
|
+
return json(metadata);
|
|
214
|
+
},
|
|
215
|
+
});
|
|
216
|
+
expect(
|
|
217
|
+
(await client.exchangeAuthorizationCode({ ...exchange, pending: mutable })).subject,
|
|
218
|
+
).toBe(signedClaims.sub);
|
|
219
|
+
});
|
|
220
|
+
test('snapshots configuration and uses form-encoded Basic credential components', async () => {
|
|
221
|
+
const input = {
|
|
222
|
+
...config,
|
|
223
|
+
scopes: [...config.scopes],
|
|
224
|
+
clientSecret: 'synthetic:secret+encoded',
|
|
225
|
+
};
|
|
226
|
+
const calls: Call[] = [];
|
|
227
|
+
const token = await signed();
|
|
228
|
+
const client = createOidcBffClient(input, {
|
|
229
|
+
now: () => nowMs,
|
|
230
|
+
fetch: async (url, init) => {
|
|
231
|
+
calls.push({ url, init });
|
|
232
|
+
if (url.endsWith('openid-configuration')) return json(metadata);
|
|
233
|
+
if (url.endsWith('jwks.json')) return json({ keys: [publicKey] });
|
|
234
|
+
return json({
|
|
235
|
+
access_token: 'access',
|
|
236
|
+
refresh_token: 'refresh',
|
|
237
|
+
id_token: token,
|
|
238
|
+
token_type: 'Bearer',
|
|
239
|
+
expires_in: 300,
|
|
240
|
+
scope: config.scopes.join(' '),
|
|
241
|
+
});
|
|
242
|
+
},
|
|
243
|
+
});
|
|
244
|
+
input.clientSecret = 'changed';
|
|
245
|
+
input.scopes.push('wrong');
|
|
246
|
+
await client.exchangeAuthorizationCode(exchange);
|
|
247
|
+
expect(new Headers(calls[1].init.headers).get('Authorization')).toBe(
|
|
248
|
+
`Basic ${Buffer.from('web-client:synthetic%3Asecret%2Bencoded').toString('base64')}`,
|
|
249
|
+
);
|
|
250
|
+
});
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
describe('signed identity evidence', () => {
|
|
254
|
+
const rejected: Record<string, JWTPayload> = {
|
|
255
|
+
issuer: { iss: 'https://foreign.example/' },
|
|
256
|
+
audience: { aud: 'ops' },
|
|
257
|
+
azp: { azp: 'foreign-client' },
|
|
258
|
+
multipleWithoutAzp: { aud: [config.clientId, 'other'] },
|
|
259
|
+
nonce: { nonce: 'foreign-nonce' },
|
|
260
|
+
expired: { exp: at - 1 },
|
|
261
|
+
futureIat: { iat: at + 6 },
|
|
262
|
+
staleIat: { iat: at - 7 },
|
|
263
|
+
staleAuth: { auth_time: at - 7 },
|
|
264
|
+
futureAuth: { auth_time: at + 6 },
|
|
265
|
+
absentAuth: { auth_time: undefined },
|
|
266
|
+
fractionalAuth: { auth_time: at + 0.5 },
|
|
267
|
+
absentNonce: { nonce: undefined },
|
|
268
|
+
emptySubject: { sub: '' },
|
|
269
|
+
hash: { at_hash: 'wrong-hash' },
|
|
270
|
+
codeHash: { c_hash: 'wrong-hash' },
|
|
271
|
+
duplicateAudience: { aud: [config.clientId, config.clientId], azp: config.clientId },
|
|
272
|
+
};
|
|
273
|
+
for (const [name, claims] of Object.entries(rejected)) {
|
|
274
|
+
test(`rejects a correctly signed token with invalid ${name}`, async () => {
|
|
275
|
+
const { client } = await fixture({ claims });
|
|
276
|
+
await expect(client.exchangeAuthorizationCode(exchange)).rejects.toBeInstanceOf(
|
|
277
|
+
OidcInvalidGrantError,
|
|
278
|
+
);
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
test('accepts multiple audiences only with matching signed azp and validates token/code hashes', async () => {
|
|
282
|
+
const hash = (value: string) =>
|
|
283
|
+
createHash('sha256').update(value).digest().subarray(0, 16).toString('base64url');
|
|
284
|
+
const { client } = await fixture({
|
|
285
|
+
claims: {
|
|
286
|
+
aud: [config.clientId, 'other'],
|
|
287
|
+
azp: config.clientId,
|
|
288
|
+
at_hash: hash('synthetic-access-token'),
|
|
289
|
+
c_hash: hash(exchange.code),
|
|
290
|
+
},
|
|
291
|
+
});
|
|
292
|
+
expect((await client.exchangeAuthorizationCode(exchange)).subject).toBe(signedClaims.sub);
|
|
293
|
+
});
|
|
294
|
+
test('rejects an ID token whose signature is changed', async () => {
|
|
295
|
+
const idToken = await signed(),
|
|
296
|
+
parts = idToken.split('.');
|
|
297
|
+
parts[2] = `${parts[2][0] === 'A' ? 'B' : 'A'}${parts[2].slice(1)}`;
|
|
298
|
+
const { client } = await fixture({ tokenString: parts.join('.') });
|
|
299
|
+
await expect(client.exchangeAuthorizationCode(exchange)).rejects.toMatchObject({
|
|
300
|
+
reason: 'id_token',
|
|
301
|
+
});
|
|
302
|
+
});
|
|
303
|
+
test('rejects an unknown signing key without falling back to another issuer or algorithm', async () => {
|
|
304
|
+
const { client } = await fixture({
|
|
305
|
+
tokenString: await signed({}, { alg: 'RS256', kid: 'foreign' }),
|
|
306
|
+
});
|
|
307
|
+
await expect(client.exchangeAuthorizationCode(exchange)).rejects.toMatchObject({
|
|
308
|
+
reason: 'id_token',
|
|
309
|
+
});
|
|
310
|
+
});
|
|
311
|
+
test('revokes minted token pairs after a signed ID token is rejected', async () => {
|
|
312
|
+
const { client, calls } = await fixture({ claims: { nonce: 'foreign-nonce' } });
|
|
313
|
+
await expect(client.exchangeAuthorizationCode(exchange)).rejects.toMatchObject({
|
|
314
|
+
reason: 'id_token',
|
|
315
|
+
});
|
|
316
|
+
expect(
|
|
317
|
+
calls
|
|
318
|
+
.filter(({ url }) => url.endsWith('/revoke'))
|
|
319
|
+
.map(({ init }) => new URLSearchParams(String(init.body)).get('token_type_hint')),
|
|
320
|
+
).toEqual(['access_token', 'refresh_token']);
|
|
321
|
+
});
|
|
322
|
+
test('rejects unsafe/private or duplicate JWKS material as provider failure', async () => {
|
|
323
|
+
for (const keys of [
|
|
324
|
+
{ keys: [{ ...publicKey, d: 'synthetic-private-material' }] },
|
|
325
|
+
{ keys: [publicKey, publicKey] },
|
|
326
|
+
{ keys: [{ ...publicKey, n: 'too-small' }] },
|
|
327
|
+
{ keys: [] },
|
|
328
|
+
]) {
|
|
329
|
+
const { client } = await fixture({ keys });
|
|
330
|
+
await expect(client.exchangeAuthorizationCode(exchange)).rejects.toMatchObject({
|
|
331
|
+
reason: 'jwks',
|
|
332
|
+
});
|
|
333
|
+
}
|
|
334
|
+
});
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
describe('token endpoint and provider failure', () => {
|
|
338
|
+
for (const [name, token] of Object.entries({
|
|
339
|
+
scopeMissing: { scope: 'openid offline_access' },
|
|
340
|
+
scopeExtra: { scope: `${config.scopes.join(' ')} ops` },
|
|
341
|
+
scopeDuplicate: { scope: 'openid offline_access openid' },
|
|
342
|
+
tokenType: { token_type: 'MAC' },
|
|
343
|
+
zeroExpiry: { expires_in: 0 },
|
|
344
|
+
hugeExpiry: { expires_in: 3601 },
|
|
345
|
+
fractionalExpiry: { expires_in: 1.5 },
|
|
346
|
+
refreshMissing: { refresh_token: undefined },
|
|
347
|
+
accessControl: { access_token: 'raw\nsecret' },
|
|
348
|
+
hugeToken: { access_token: 'a'.repeat(16_385) },
|
|
349
|
+
})) {
|
|
350
|
+
test(`rejects invalid token response: ${name}`, async () => {
|
|
351
|
+
const { client } = await fixture({ token });
|
|
352
|
+
await expect(client.exchangeAuthorizationCode(exchange)).rejects.toMatchObject({
|
|
353
|
+
reason: 'token_response',
|
|
354
|
+
});
|
|
355
|
+
});
|
|
356
|
+
}
|
|
357
|
+
test('distinguishes invalid grant from provider HTTP failure without returning provider descriptions', async () => {
|
|
358
|
+
const invalid = await fixture({
|
|
359
|
+
tokenResponse: json(
|
|
360
|
+
{ error: 'invalid_grant', error_description: 'synthetic-code secret' },
|
|
361
|
+
400,
|
|
362
|
+
),
|
|
363
|
+
});
|
|
364
|
+
await expect(invalid.client.exchangeAuthorizationCode(exchange)).rejects.toMatchObject({
|
|
365
|
+
reason: 'invalid_grant',
|
|
366
|
+
message: 'Sign-in could not be verified',
|
|
367
|
+
});
|
|
368
|
+
const unavailable = await fixture({
|
|
369
|
+
tokenResponse: json({ error: 'invalid_client', error_description: config.clientSecret }, 401),
|
|
370
|
+
});
|
|
371
|
+
await expect(unavailable.client.exchangeAuthorizationCode(exchange)).rejects.toBeInstanceOf(
|
|
372
|
+
OidcUnavailableError,
|
|
373
|
+
);
|
|
374
|
+
});
|
|
375
|
+
for (const field of [
|
|
376
|
+
'issuer',
|
|
377
|
+
'authorization_endpoint',
|
|
378
|
+
'token_endpoint',
|
|
379
|
+
'revocation_endpoint',
|
|
380
|
+
'jwks_uri',
|
|
381
|
+
]) {
|
|
382
|
+
test(`rejects a changed metadata ${field} before credentials or code leave the BFF`, async () => {
|
|
383
|
+
const { client, calls } = await fixture({
|
|
384
|
+
discovery: { ...metadata, [field]: 'https://foreign.example/steal' },
|
|
385
|
+
});
|
|
386
|
+
await expect(client.exchangeAuthorizationCode(exchange)).rejects.toMatchObject({
|
|
387
|
+
reason: 'metadata',
|
|
388
|
+
});
|
|
389
|
+
expect(calls).toHaveLength(1);
|
|
390
|
+
});
|
|
391
|
+
}
|
|
392
|
+
test('rejects redirected, oversize, malformed and invalid UTF-8 responses', async () => {
|
|
393
|
+
for (const response of [
|
|
394
|
+
new Response(null, { status: 302 }),
|
|
395
|
+
new Response('x'.repeat(65_537)),
|
|
396
|
+
new Response('{}', { headers: { 'content-length': '65537' } }),
|
|
397
|
+
new Response('not-json'),
|
|
398
|
+
new Response(new Uint8Array([0xff, 0xfe])),
|
|
399
|
+
]) {
|
|
400
|
+
const { client } = await fixture({ handle: () => response });
|
|
401
|
+
await expect(client.beginAuthorization(pending)).rejects.toBeInstanceOf(OidcUnavailableError);
|
|
402
|
+
}
|
|
403
|
+
});
|
|
404
|
+
test('bounds a transport that ignores abort and hides its raw exception', async () => {
|
|
405
|
+
const hanging = await fixture({ timeoutMs: 20, handle: () => new Promise<Response>(() => {}) });
|
|
406
|
+
await expect(hanging.client.beginAuthorization(pending)).rejects.toMatchObject({
|
|
407
|
+
reason: 'timeout',
|
|
408
|
+
});
|
|
409
|
+
const failed = await fixture({ handle: () => Promise.reject(new Error(config.clientSecret)) });
|
|
410
|
+
await expect(failed.client.beginAuthorization(pending)).rejects.toMatchObject({
|
|
411
|
+
reason: 'network',
|
|
412
|
+
message: 'Sign-in provider is unavailable',
|
|
413
|
+
});
|
|
414
|
+
});
|
|
415
|
+
test('bounds a hanging JWKS response stream and cancels it', async () => {
|
|
416
|
+
let cancelled = false;
|
|
417
|
+
const { client } = await fixture({
|
|
418
|
+
timeoutMs: 30,
|
|
419
|
+
handle: ({ url }) =>
|
|
420
|
+
url.endsWith('jwks.json')
|
|
421
|
+
? new Response(
|
|
422
|
+
new ReadableStream({
|
|
423
|
+
start(controller) {
|
|
424
|
+
controller.enqueue(new TextEncoder().encode('{"keys":['));
|
|
425
|
+
},
|
|
426
|
+
cancel() {
|
|
427
|
+
cancelled = true;
|
|
428
|
+
},
|
|
429
|
+
}),
|
|
430
|
+
)
|
|
431
|
+
: undefined,
|
|
432
|
+
});
|
|
433
|
+
await expect(client.exchangeAuthorizationCode(exchange)).rejects.toMatchObject({
|
|
434
|
+
reason: 'timeout',
|
|
435
|
+
});
|
|
436
|
+
expect(cancelled).toBe(true);
|
|
437
|
+
});
|
|
438
|
+
test('uses one operation deadline across discovery, token exchange and JWKS', async () => {
|
|
439
|
+
const { client, calls } = await fixture({
|
|
440
|
+
timeoutMs: 45,
|
|
441
|
+
handle: async ({ url }) => {
|
|
442
|
+
await new Promise((resolve) => setTimeout(resolve, 25));
|
|
443
|
+
if (url.endsWith('openid-configuration')) return json(metadata);
|
|
444
|
+
return json({ error: 'server_error' }, 500);
|
|
445
|
+
},
|
|
446
|
+
});
|
|
447
|
+
await expect(client.exchangeAuthorizationCode(exchange)).rejects.toMatchObject({
|
|
448
|
+
reason: 'timeout',
|
|
449
|
+
});
|
|
450
|
+
expect(calls).toHaveLength(2);
|
|
451
|
+
});
|
|
452
|
+
});
|
|
453
|
+
|
|
454
|
+
describe('per-app revocation', () => {
|
|
455
|
+
test('revokes both app tokens, accepts empty RFC7009 success bodies and does not log out shared provider SSO', async () => {
|
|
456
|
+
const { client, calls } = await fixture();
|
|
457
|
+
await client.revokeTokenPair({
|
|
458
|
+
accessToken: 'synthetic-access',
|
|
459
|
+
refreshToken: 'synthetic-refresh',
|
|
460
|
+
});
|
|
461
|
+
const writes = calls.filter(({ init }) => init.method === 'POST');
|
|
462
|
+
expect(
|
|
463
|
+
writes.map(({ init }) => Object.fromEntries(new URLSearchParams(String(init.body)))),
|
|
464
|
+
).toEqual([
|
|
465
|
+
{ token: 'synthetic-access', token_type_hint: 'access_token' },
|
|
466
|
+
{ token: 'synthetic-refresh', token_type_hint: 'refresh_token' },
|
|
467
|
+
]);
|
|
468
|
+
expect(
|
|
469
|
+
writes.every(
|
|
470
|
+
({ url, init }) =>
|
|
471
|
+
url === metadata.revocation_endpoint && new Headers(init.headers).has('Authorization'),
|
|
472
|
+
),
|
|
473
|
+
).toBe(true);
|
|
474
|
+
expect(calls.some(({ url }) => url.includes('sessions') || url.includes('logout'))).toBe(false);
|
|
475
|
+
});
|
|
476
|
+
test('reports a revocation failure while attempting both token revocations', async () => {
|
|
477
|
+
const { client, calls } = await fixture({
|
|
478
|
+
handle: ({ url }) =>
|
|
479
|
+
url.endsWith('/revoke') ? json({ error: 'server_error' }, 503) : undefined,
|
|
480
|
+
});
|
|
481
|
+
await expect(
|
|
482
|
+
client.revokeTokenPair({ accessToken: 'access', refreshToken: 'refresh' }),
|
|
483
|
+
).rejects.toBeInstanceOf(OidcUnavailableError);
|
|
484
|
+
expect(calls.filter(({ url }) => url.endsWith('/revoke'))).toHaveLength(2);
|
|
485
|
+
});
|
|
486
|
+
test('accepts access-only or refresh-only retirement and refuses an empty or entirely unsafe subset', async () => {
|
|
487
|
+
for (const pair of [
|
|
488
|
+
{ accessToken: 'access' },
|
|
489
|
+
{ refreshToken: 'refresh' },
|
|
490
|
+
{ accessToken: 'access', refreshToken: 'bad\nrefresh' },
|
|
491
|
+
]) {
|
|
492
|
+
const { client, calls } = await fixture();
|
|
493
|
+
await client.revokeTokenPair(pair);
|
|
494
|
+
expect(calls.filter(({ url }) => url.endsWith('/revoke'))).toHaveLength(1);
|
|
495
|
+
}
|
|
496
|
+
for (const pair of [{}, { accessToken: 'bad\naccess' }]) {
|
|
497
|
+
const { client, calls } = await fixture();
|
|
498
|
+
await expect(client.revokeTokenPair(pair)).rejects.toMatchObject({
|
|
499
|
+
reason: 'token_response',
|
|
500
|
+
});
|
|
501
|
+
expect(calls).toHaveLength(0);
|
|
502
|
+
}
|
|
503
|
+
});
|
|
504
|
+
});
|
|
505
|
+
|
|
506
|
+
describe('durable minted-token handoff', () => {
|
|
507
|
+
test('persists only safely parsed tokens before JWKS and signed ID-token validation', async () => {
|
|
508
|
+
const events: string[] = [];
|
|
509
|
+
let queued: unknown;
|
|
510
|
+
const { client } = await fixture({
|
|
511
|
+
claims: { nonce: 'foreign-nonce' },
|
|
512
|
+
onTokenPairIssued: async (pair) => {
|
|
513
|
+
queued = pair;
|
|
514
|
+
events.push('persisted');
|
|
515
|
+
},
|
|
516
|
+
handle: ({ url }) => {
|
|
517
|
+
if (url.endsWith('jwks.json')) events.push('jwks');
|
|
518
|
+
return undefined;
|
|
519
|
+
},
|
|
520
|
+
});
|
|
521
|
+
await expect(client.exchangeAuthorizationCode(exchange)).rejects.toMatchObject({
|
|
522
|
+
reason: 'id_token',
|
|
523
|
+
});
|
|
524
|
+
expect(events).toEqual(['persisted', 'jwks']);
|
|
525
|
+
expect(queued).toEqual({
|
|
526
|
+
accessToken: 'synthetic-access-token',
|
|
527
|
+
refreshToken: 'synthetic-refresh-token',
|
|
528
|
+
expiresAtMs: nowMs + 3_600_000,
|
|
529
|
+
});
|
|
530
|
+
expect(Object.isFrozen(queued)).toBe(true);
|
|
531
|
+
});
|
|
532
|
+
test('rejects admission and attempts both revocations if persistence fails, without exposing its raw error', async () => {
|
|
533
|
+
const { client, calls } = await fixture({
|
|
534
|
+
onTokenPairIssued: () =>
|
|
535
|
+
Promise.reject(new Error('synthetic-access-token synthetic-refresh-token')),
|
|
536
|
+
});
|
|
537
|
+
await expect(client.exchangeAuthorizationCode(exchange)).rejects.toMatchObject({
|
|
538
|
+
reason: 'persistence',
|
|
539
|
+
message: 'Sign-in provider is unavailable',
|
|
540
|
+
});
|
|
541
|
+
expect(calls.some(({ url }) => url.endsWith('jwks.json'))).toBe(false);
|
|
542
|
+
expect(calls.filter(({ url }) => url.endsWith('/revoke'))).toHaveLength(2);
|
|
543
|
+
});
|
|
544
|
+
test('stages safe tokens before scope, expiry, type and ID-token rejection without ever granting admission', async () => {
|
|
545
|
+
for (const token of [
|
|
546
|
+
{ token_type: 'MAC' },
|
|
547
|
+
{ scope: 'openid admin' },
|
|
548
|
+
{ expires_in: -1 },
|
|
549
|
+
{ id_token: 'invalid' },
|
|
550
|
+
]) {
|
|
551
|
+
let queued: unknown;
|
|
552
|
+
const { client } = await fixture({
|
|
553
|
+
token,
|
|
554
|
+
onTokenPairIssued: async (pair) => {
|
|
555
|
+
queued = pair;
|
|
556
|
+
},
|
|
557
|
+
});
|
|
558
|
+
await expect(client.exchangeAuthorizationCode(exchange)).rejects.toBeInstanceOf(
|
|
559
|
+
OidcInvalidGrantError,
|
|
560
|
+
);
|
|
561
|
+
expect(queued).toEqual({
|
|
562
|
+
accessToken: 'synthetic-access-token',
|
|
563
|
+
refreshToken: 'synthetic-refresh-token',
|
|
564
|
+
expiresAtMs: nowMs + 3_600_000,
|
|
565
|
+
});
|
|
566
|
+
}
|
|
567
|
+
});
|
|
568
|
+
test('stages the safe subset when one credential is missing or malformed, using a retirement-only expiry hint', async () => {
|
|
569
|
+
for (const [token, expected] of [
|
|
570
|
+
[
|
|
571
|
+
{ refresh_token: undefined, expires_in: 'malformed' },
|
|
572
|
+
{ accessToken: 'synthetic-access-token' },
|
|
573
|
+
],
|
|
574
|
+
[{ refresh_token: 'bad\nrefresh' }, { accessToken: 'synthetic-access-token' }],
|
|
575
|
+
[{ access_token: 'bad\naccess' }, { refreshToken: 'synthetic-refresh-token' }],
|
|
576
|
+
]) {
|
|
577
|
+
let queued: unknown;
|
|
578
|
+
const { client } = await fixture({
|
|
579
|
+
token,
|
|
580
|
+
onTokenPairIssued: async (pair) => {
|
|
581
|
+
queued = pair;
|
|
582
|
+
},
|
|
583
|
+
});
|
|
584
|
+
await expect(client.exchangeAuthorizationCode(exchange)).rejects.toMatchObject({
|
|
585
|
+
reason: 'token_response',
|
|
586
|
+
});
|
|
587
|
+
expect(queued).toEqual({ ...expected, expiresAtMs: nowMs + 3_600_000 });
|
|
588
|
+
}
|
|
589
|
+
});
|
|
590
|
+
test('does not persist a response without any bounded safe credential', async () => {
|
|
591
|
+
let calls = 0;
|
|
592
|
+
const { client } = await fixture({
|
|
593
|
+
token: { access_token: 'bad\naccess', refresh_token: undefined },
|
|
594
|
+
onTokenPairIssued: async () => {
|
|
595
|
+
calls += 1;
|
|
596
|
+
},
|
|
597
|
+
});
|
|
598
|
+
await expect(client.exchangeAuthorizationCode(exchange)).rejects.toMatchObject({
|
|
599
|
+
reason: 'token_response',
|
|
600
|
+
});
|
|
601
|
+
expect(calls).toBe(0);
|
|
602
|
+
});
|
|
603
|
+
test('keeps the total operation deadline when a persistence hook hangs', async () => {
|
|
604
|
+
const { client, calls } = await fixture({
|
|
605
|
+
timeoutMs: 20,
|
|
606
|
+
onTokenPairIssued: () => new Promise<void>(() => {}),
|
|
607
|
+
});
|
|
608
|
+
await expect(client.exchangeAuthorizationCode(exchange)).rejects.toMatchObject({
|
|
609
|
+
reason: 'timeout',
|
|
610
|
+
});
|
|
611
|
+
expect(calls.some(({ url }) => url.endsWith('jwks.json'))).toBe(false);
|
|
612
|
+
});
|
|
613
|
+
});
|
|
614
|
+
|
|
615
|
+
test('rejects unsafe configuration instead of discovery or legacy-provider fallback', () => {
|
|
616
|
+
for (const change of [
|
|
617
|
+
{ issuer: 'https://foreign.example.test' },
|
|
618
|
+
{ redirectUri: 'https://app.example.test/callback?evil=1' },
|
|
619
|
+
{ redirectUri: 'http://app.example.test/callback' },
|
|
620
|
+
{ scopes: ['openid'] },
|
|
621
|
+
{
|
|
622
|
+
endpoints: {
|
|
623
|
+
...config.endpoints,
|
|
624
|
+
token: 'https://user:password@identity.example.test/token',
|
|
625
|
+
},
|
|
626
|
+
},
|
|
627
|
+
{ timeoutMs: 0 },
|
|
628
|
+
{ clockToleranceSeconds: 11 },
|
|
629
|
+
]) {
|
|
630
|
+
expect(() => createOidcBffClient({ ...config, ...change })).toThrow(TypeError);
|
|
631
|
+
}
|
|
632
|
+
});
|
|
633
|
+
|
|
634
|
+
test('requires exact configured endpoints and rejects reserved provider-parameter overrides', () => {
|
|
635
|
+
expect(() =>
|
|
636
|
+
createOidcBffClient({ ...config, authorizationParameters: { state: 'foreign-state' } }),
|
|
637
|
+
).toThrow(TypeError);
|
|
638
|
+
expect(() =>
|
|
639
|
+
createOidcBffClient({
|
|
640
|
+
...config,
|
|
641
|
+
endpoints: { ...config.endpoints, token: 'http://identity.example.test/token' },
|
|
642
|
+
}),
|
|
643
|
+
).toThrow(TypeError);
|
|
644
|
+
expect(() => createOidcBffClient({ ...config, maxAccessTokenSeconds: 0 })).toThrow(TypeError);
|
|
645
|
+
});
|
|
646
|
+
|
|
647
|
+
test('does not extend provider expiry by custody hook latency or admit a token already expired during custody', async () => {
|
|
648
|
+
let clock = nowMs;
|
|
649
|
+
let stagedExpiry: number | undefined;
|
|
650
|
+
const { client } = await fixture({
|
|
651
|
+
token: { expires_in: 1 },
|
|
652
|
+
now: () => clock,
|
|
653
|
+
onTokenPairIssued: (pair) => {
|
|
654
|
+
stagedExpiry = pair.expiresAtMs;
|
|
655
|
+
clock += 2000;
|
|
656
|
+
return Promise.resolve();
|
|
657
|
+
},
|
|
658
|
+
});
|
|
659
|
+
await expect(client.exchangeAuthorizationCode(exchange)).rejects.toMatchObject({
|
|
660
|
+
reason: 'timeout',
|
|
661
|
+
});
|
|
662
|
+
expect(stagedExpiry).toBe(nowMs + 1000);
|
|
663
|
+
});
|