@llm4ts/shell 2.27.0 → 2.28.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/kits/soap-ace/README.md +68 -0
- package/kits/soap-ace/fixtures/demo-bank-soap/RUNBOOK.md +15 -0
- package/kits/soap-ace/flows/lib/soap/Auth.js +133 -11
- package/kits/soap-ace/flows/lib/soap/Call.js +17 -5
- package/kits/soap-ace/flows/lib/soap/Discover.js +31 -3
- package/kits/soap-ace/flows/lib/soap/Envelope.js +29 -4
- package/kits/soap-ace/flows/lib/soap/Explore.js +1 -0
- package/kits/soap-ace/flows/lib/soap/Setup.js +202 -0
- package/kits/soap-ace/flows/lib/soap/Sts.js +216 -0
- package/kits/soap-ace/flows/lib/soap/Stub.js +8 -1
- package/kits/soap-ace/flows/lib/soap/Transport.js +245 -75
- package/kits/soap-ace/flows/lib/soap/Xml.js +4 -2
- package/kits/soap-ace/flows/soap-discover.js +1 -1
- package/kits/soap-ace/flows/soap-explore.js +20 -3
- package/kits/soap-ace/flows/soap-sample.js +143 -12
- package/package.json +4 -4
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
import * as Duration from "effect/Duration";
|
|
2
|
+
import * as Effect from "effect/Effect";
|
|
3
|
+
import * as Schema from "effect/Schema";
|
|
4
|
+
import { resolveProxy, resolveTls } from "./Auth.js";
|
|
5
|
+
import { profilePath, readIfPresent, servicePaths } from "./Discover.js";
|
|
6
|
+
import { renderDefaultStsTemplate, stsTemplatePath, templatePlaceholders } from "./Sts.js";
|
|
7
|
+
import { displayUrl } from "./Transport.js";
|
|
8
|
+
const proxyWords = (proxy) => proxy === undefined || proxy === "none"
|
|
9
|
+
? "direct (no proxy)"
|
|
10
|
+
: `via ${proxy.url}${(proxy.noProxy ?? []).length === 0 ? "" : `, direct for ${proxy.noProxy?.join(", ")}`}${proxy.auth === undefined ? "" : ", with proxy credentials"}`;
|
|
11
|
+
const tlsWords = (tls) => {
|
|
12
|
+
if (tls === undefined) {
|
|
13
|
+
return "system roots";
|
|
14
|
+
}
|
|
15
|
+
const parts = [];
|
|
16
|
+
parts.push(tls.trust === "ca-only"
|
|
17
|
+
? "profile CA only"
|
|
18
|
+
: tls.ca === undefined && tls.caDir === undefined
|
|
19
|
+
? "system roots"
|
|
20
|
+
: "system roots + profile CA");
|
|
21
|
+
if (tls.ca !== undefined)
|
|
22
|
+
parts.push(`ca ${tls.ca}`);
|
|
23
|
+
if (tls.caDir !== undefined)
|
|
24
|
+
parts.push(`caDir ${tls.caDir}`);
|
|
25
|
+
if (tls.pfx !== undefined)
|
|
26
|
+
parts.push("client pfx");
|
|
27
|
+
if (tls.cert !== undefined)
|
|
28
|
+
parts.push("client cert+key");
|
|
29
|
+
if (tls.servername !== undefined)
|
|
30
|
+
parts.push(`servername ${tls.servername}`);
|
|
31
|
+
return parts.join(", ");
|
|
32
|
+
};
|
|
33
|
+
const authWords = (side) => side?.auth === undefined || side.auth.scheme === "none"
|
|
34
|
+
? "no auth"
|
|
35
|
+
: side.auth.scheme === "basic"
|
|
36
|
+
? `basic as ${side.auth.user}`
|
|
37
|
+
: "bearer";
|
|
38
|
+
/** The proxy variables of the environment, which the soap flows never read. */
|
|
39
|
+
export const ignoredProxyVariables = (environment) => ["HTTP_PROXY", "HTTPS_PROXY", "NO_PROXY", "http_proxy", "https_proxy", "no_proxy"].filter((name) => (environment[name] ?? "").trim() !== "");
|
|
40
|
+
/** `check`: the selected profile as the decisions it makes, line by line. No network. */
|
|
41
|
+
export const checkReport = (options) => Effect.gen(function* () {
|
|
42
|
+
const path = profilePath(options.service, options.env);
|
|
43
|
+
const lines = [
|
|
44
|
+
`profile: ${path}${options.profile === undefined ? " (missing)" : ""}`
|
|
45
|
+
];
|
|
46
|
+
const profile = options.profile;
|
|
47
|
+
if (profile === undefined) {
|
|
48
|
+
lines.push(` write it with at least {"environment": "dev"}; see the soap-ace README`);
|
|
49
|
+
}
|
|
50
|
+
else {
|
|
51
|
+
lines.push(`environment: ${profile.environment}`);
|
|
52
|
+
lines.push(`call endpoint: ${profile.endpoint ?? "(from the WSDL)"}`);
|
|
53
|
+
for (const [name, side] of [
|
|
54
|
+
["fetch", profile.fetch],
|
|
55
|
+
["call", profile.call]
|
|
56
|
+
]) {
|
|
57
|
+
lines.push(`${name}: ${authWords(side)}; ${proxyWords(side?.proxy)}; tls ${tlsWords(side?.tls)}`);
|
|
58
|
+
}
|
|
59
|
+
if (profile.wsSecurity !== undefined) {
|
|
60
|
+
lines.push(`ws-security: UsernameToken (${profile.wsSecurity.passwordType})`);
|
|
61
|
+
}
|
|
62
|
+
if (profile.sts === undefined) {
|
|
63
|
+
lines.push("sts: none");
|
|
64
|
+
}
|
|
65
|
+
else {
|
|
66
|
+
const template = stsTemplatePath(options.service, profile.sts);
|
|
67
|
+
const text = yield* readIfPresent(options.workspace, template);
|
|
68
|
+
lines.push(`sts: ${displayUrl(profile.sts.endpoint)}; ${proxyWords(profile.sts.proxy)}; tls ${tlsWords(profile.sts.tls)}; token element ${profile.sts.token?.element ?? "Assertion"}`);
|
|
69
|
+
lines.push(text === undefined
|
|
70
|
+
? ` template ${template} missing: write it with soap-sample "sts init"`
|
|
71
|
+
: ` template ${template} fills ${templatePlaceholders(text).join(", ") || "(no placeholders)"}`);
|
|
72
|
+
}
|
|
73
|
+
const trust = yield* options.workspace.discover(`${servicePaths(options.service).trustDir}/*.pem`);
|
|
74
|
+
if (trust.length > 0) {
|
|
75
|
+
lines.push(`trusted chains: ${trust.map((file) => file.split("/").at(-1)).join(", ")}`);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
const ignored = ignoredProxyVariables(options.environment);
|
|
79
|
+
if (ignored.length > 0) {
|
|
80
|
+
lines.push(`ignored: ${ignored.join(", ")} are set; the soap flows use only the profile's proxy`);
|
|
81
|
+
}
|
|
82
|
+
return lines;
|
|
83
|
+
});
|
|
84
|
+
// ---------------------------------------------------------------------------
|
|
85
|
+
// trust
|
|
86
|
+
export class TrustError extends Schema.TaggedError()("TrustError", {
|
|
87
|
+
side: Schema.String,
|
|
88
|
+
detail: Schema.String
|
|
89
|
+
}) {
|
|
90
|
+
get message() {
|
|
91
|
+
return `trust ${this.side}: ${this.detail}`;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
const endpointOf = (profile, side, fallback) => (side === "sts" ? profile.sts?.endpoint : (profile.endpoint ?? fallback));
|
|
95
|
+
const sideConfig = (profile, side) => side === "sts"
|
|
96
|
+
? {
|
|
97
|
+
...(profile.sts?.tls === undefined ? {} : { tls: profile.sts.tls }),
|
|
98
|
+
...(profile.sts?.proxy === undefined ? {} : { proxy: profile.sts.proxy })
|
|
99
|
+
}
|
|
100
|
+
: {
|
|
101
|
+
...(profile[side]?.tls === undefined ? {} : { tls: profile[side]?.tls }),
|
|
102
|
+
...(profile[side]?.proxy === undefined ? {} : { proxy: profile[side]?.proxy })
|
|
103
|
+
};
|
|
104
|
+
export const describeChain = (chain) => [
|
|
105
|
+
`${chain.host}: ${chain.certificates.length} certificate(s)${chain.trustedBySystem ? ", trusted by the system roots" : `, NOT trusted by the system roots (${chain.authorizationError ?? "unknown issuer"})`}`,
|
|
106
|
+
...chain.certificates.flatMap((certificate, index) => [
|
|
107
|
+
` ${index + 1}. ${certificate.subject}`,
|
|
108
|
+
` issuer: ${certificate.issuer}`,
|
|
109
|
+
` valid: ${certificate.validFrom} → ${certificate.validTo}`,
|
|
110
|
+
` sha256: ${certificate.fingerprint256}`
|
|
111
|
+
])
|
|
112
|
+
];
|
|
113
|
+
const pemFileFor = (service, host) => `${servicePaths(service).trustDir}/${host.replace(/[^A-Za-z0-9.-]+/g, "_")}.pem`;
|
|
114
|
+
/** Sets `<side>.tls.ca` in the selected profile's JSON text to the pinned file, keeping everything else. */
|
|
115
|
+
const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
116
|
+
export const profileWithPinnedCa = (text, side, pemPath) => Effect.try({
|
|
117
|
+
try: () => {
|
|
118
|
+
const parsed = JSON.parse(text);
|
|
119
|
+
if (!isRecord(parsed)) {
|
|
120
|
+
throw new Error("not an object");
|
|
121
|
+
}
|
|
122
|
+
const sideValue = parsed[side];
|
|
123
|
+
const sideObject = isRecord(sideValue) ? sideValue : {};
|
|
124
|
+
const tlsValue = sideObject["tls"];
|
|
125
|
+
const tlsObject = isRecord(tlsValue) ? tlsValue : {};
|
|
126
|
+
const edited = {
|
|
127
|
+
...parsed,
|
|
128
|
+
[side]: { ...sideObject, tls: { ...tlsObject, ca: `file:${pemPath}` } }
|
|
129
|
+
};
|
|
130
|
+
return `${JSON.stringify(edited, null, 2)}\n`;
|
|
131
|
+
},
|
|
132
|
+
catch: () => new TrustError({ side, detail: "the profile is not a JSON object; set tls.ca by hand" })
|
|
133
|
+
});
|
|
134
|
+
/** `trust <side>`: show the chain, pin it on a yes. */
|
|
135
|
+
export const trustSide = (options) => Effect.gen(function* () {
|
|
136
|
+
const endpoint = endpointOf(options.profile, options.side, options.fallbackEndpoint);
|
|
137
|
+
if (endpoint === undefined) {
|
|
138
|
+
return yield* new TrustError({
|
|
139
|
+
side: options.side,
|
|
140
|
+
detail: options.side === "sts"
|
|
141
|
+
? "the profile names no sts"
|
|
142
|
+
: "no endpoint in the profile or the WSDL"
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
const config = sideConfig(options.profile, options.side);
|
|
146
|
+
const proxy = yield* resolveProxy(options.secrets, config.proxy);
|
|
147
|
+
const tls = yield* resolveTls(options.secrets, config.tls);
|
|
148
|
+
const chain = yield* options.transport.peerChain(endpoint, {
|
|
149
|
+
...(proxy === undefined ? {} : { proxy }),
|
|
150
|
+
...(tls?.servername === undefined ? {} : { servername: tls.servername }),
|
|
151
|
+
timeout: Duration.seconds(20)
|
|
152
|
+
});
|
|
153
|
+
const lines = describeChain(chain);
|
|
154
|
+
if (chain.trustedBySystem) {
|
|
155
|
+
return { _tag: "AlreadyTrusted", lines };
|
|
156
|
+
}
|
|
157
|
+
if (chain.certificates.length === 0) {
|
|
158
|
+
return yield* new TrustError({
|
|
159
|
+
side: options.side,
|
|
160
|
+
detail: "the server presented no certificate"
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
const pemPath = pemFileFor(options.service, chain.host);
|
|
164
|
+
const existing = yield* readIfPresent(options.workspace, pemPath);
|
|
165
|
+
const shown = existing === undefined
|
|
166
|
+
? lines
|
|
167
|
+
: [
|
|
168
|
+
...lines,
|
|
169
|
+
`a chain for ${chain.host} is already pinned at ${pemPath}; this one replaces it`
|
|
170
|
+
];
|
|
171
|
+
if (!(yield* options.confirm(shown))) {
|
|
172
|
+
return { _tag: "Declined", lines: shown };
|
|
173
|
+
}
|
|
174
|
+
yield* options.workspace.write(pemPath, chain.certificates.map((c) => c.pem).join(""));
|
|
175
|
+
const profileFile = profilePath(options.service, options.env);
|
|
176
|
+
const text = (yield* readIfPresent(options.workspace, profileFile)) ?? "{}";
|
|
177
|
+
yield* options.workspace.write(profileFile, yield* profileWithPinnedCa(text, options.side, pemPath));
|
|
178
|
+
return {
|
|
179
|
+
_tag: "Pinned",
|
|
180
|
+
lines: shown,
|
|
181
|
+
pem: pemPath,
|
|
182
|
+
profile: profileFile,
|
|
183
|
+
replaced: existing !== undefined
|
|
184
|
+
};
|
|
185
|
+
});
|
|
186
|
+
// ---------------------------------------------------------------------------
|
|
187
|
+
// sts init
|
|
188
|
+
/** `sts init`: the default template, unless one exists. */
|
|
189
|
+
export const initStsTemplate = (workspace, service, profile) => Effect.gen(function* () {
|
|
190
|
+
if (profile.sts === undefined) {
|
|
191
|
+
return yield* new TrustError({
|
|
192
|
+
side: "sts",
|
|
193
|
+
detail: 'the profile names no "sts"; add it first'
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
const path = stsTemplatePath(service, profile.sts);
|
|
197
|
+
if ((yield* readIfPresent(workspace, path)) !== undefined) {
|
|
198
|
+
return { path, written: false };
|
|
199
|
+
}
|
|
200
|
+
yield* workspace.write(path, renderDefaultStsTemplate(profile.sts.soapVersion ?? "1.1"));
|
|
201
|
+
return { path, written: true };
|
|
202
|
+
});
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
2
|
+
import * as Duration from "effect/Duration";
|
|
3
|
+
import * as Effect from "effect/Effect";
|
|
4
|
+
import * as Redacted from "effect/Redacted";
|
|
5
|
+
import * as Ref from "effect/Ref";
|
|
6
|
+
import * as Schema from "effect/Schema";
|
|
7
|
+
import { defaultStsTemplate, defaultTokenElement, resolvePrincipal, resolveProxy, resolveText, resolveTls } from "./Auth.js";
|
|
8
|
+
import { envelopeNamespace, readEnvelope, soapHeaders, WsseNamespace, WsuNamespace } from "./Envelope.js";
|
|
9
|
+
import { serviceDirectory } from "./Discover.js";
|
|
10
|
+
import { displayUrl } from "./Transport.js";
|
|
11
|
+
import { decodeXml, parseXml } from "./Xml.js";
|
|
12
|
+
// A SAML bearer token from an STS (WS-Trust): the request is a template the
|
|
13
|
+
// user owns under the service folder, filled with the credential, a nonce
|
|
14
|
+
// and timestamps, posted with the STS side's auth, TLS and proxy; the first
|
|
15
|
+
// element named `token.element` in the answer is carried as the raw bytes
|
|
16
|
+
// of the response, so a signature over it survives. The token lives in
|
|
17
|
+
// memory for the run only and is renewed before it expires.
|
|
18
|
+
export class StsError extends Schema.TaggedError()("StsError", {
|
|
19
|
+
endpoint: Schema.String,
|
|
20
|
+
detail: Schema.String
|
|
21
|
+
}) {
|
|
22
|
+
get message() {
|
|
23
|
+
return `STS ${this.endpoint}: ${this.detail}`;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
export const stsTemplatePath = (service, config) => `${serviceDirectory(service)}/${config.template ?? defaultStsTemplate}`;
|
|
27
|
+
const WsTrust = "http://docs.oasis-open.org/ws-sx/ws-trust/200512";
|
|
28
|
+
const WsPolicy = "http://schemas.xmlsoap.org/ws/2004/09/policy";
|
|
29
|
+
const WsAddressing = "http://www.w3.org/2005/08/addressing";
|
|
30
|
+
const TokenProfile = "http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-username-token-profile-1.0";
|
|
31
|
+
/** The WS-Trust 1.3 Issue request `sts init` writes; every placeholder is filled per call. */
|
|
32
|
+
export const renderDefaultStsTemplate = (soapVersion = "1.1") => [
|
|
33
|
+
'<?xml version="1.0" encoding="UTF-8"?>',
|
|
34
|
+
"<!-- The STS request soap-sample fills before every token call. Placeholders:",
|
|
35
|
+
" {{username}} {{password}} {{created}} {{expires}} {{nonce}} {{appliesTo}}.",
|
|
36
|
+
" Edit freely: this file is yours; soap-sample only fills the placeholders. -->",
|
|
37
|
+
`<soapenv:Envelope xmlns:soapenv="${envelopeNamespace(soapVersion)}"`,
|
|
38
|
+
` xmlns:wsse="${WsseNamespace}"`,
|
|
39
|
+
` xmlns:wsu="${WsuNamespace}"`,
|
|
40
|
+
` xmlns:wst="${WsTrust}"`,
|
|
41
|
+
` xmlns:wsp="${WsPolicy}"`,
|
|
42
|
+
` xmlns:wsa="${WsAddressing}">`,
|
|
43
|
+
" <soapenv:Header>",
|
|
44
|
+
` <wsa:Action>${WsTrust}/RST/Issue</wsa:Action>`,
|
|
45
|
+
' <wsse:Security soapenv:mustUnderstand="1">',
|
|
46
|
+
' <wsu:Timestamp wsu:Id="TS-1">',
|
|
47
|
+
" <wsu:Created>{{created}}</wsu:Created>",
|
|
48
|
+
" <wsu:Expires>{{expires}}</wsu:Expires>",
|
|
49
|
+
" </wsu:Timestamp>",
|
|
50
|
+
' <wsse:UsernameToken wsu:Id="UsernameToken-1">',
|
|
51
|
+
" <wsse:Username>{{username}}</wsse:Username>",
|
|
52
|
+
` <wsse:Password Type="${TokenProfile}#PasswordText">{{password}}</wsse:Password>`,
|
|
53
|
+
" <wsse:Nonce>{{nonce}}</wsse:Nonce>",
|
|
54
|
+
" <wsu:Created>{{created}}</wsu:Created>",
|
|
55
|
+
" </wsse:UsernameToken>",
|
|
56
|
+
" </wsse:Security>",
|
|
57
|
+
" </soapenv:Header>",
|
|
58
|
+
" <soapenv:Body>",
|
|
59
|
+
" <wst:RequestSecurityToken>",
|
|
60
|
+
` <wst:RequestType>${WsTrust}/Issue</wst:RequestType>`,
|
|
61
|
+
" <wst:TokenType>http://docs.oasis-open.org/wss/oasis-wss-saml-token-profile-1.1#SAMLV2.0</wst:TokenType>",
|
|
62
|
+
` <wst:KeyType>${WsTrust}/Bearer</wst:KeyType>`,
|
|
63
|
+
" <wsp:AppliesTo>",
|
|
64
|
+
" <wsa:EndpointReference>",
|
|
65
|
+
" <wsa:Address>{{appliesTo}}</wsa:Address>",
|
|
66
|
+
" </wsa:EndpointReference>",
|
|
67
|
+
" </wsp:AppliesTo>",
|
|
68
|
+
" </wst:RequestSecurityToken>",
|
|
69
|
+
" </soapenv:Body>",
|
|
70
|
+
"</soapenv:Envelope>",
|
|
71
|
+
""
|
|
72
|
+
].join("\n");
|
|
73
|
+
const escapeXml = (value) => value.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
74
|
+
/** Fills `{{name}}` placeholders; values are escaped for XML text. Unknown placeholders stay. */
|
|
75
|
+
export const fillStsTemplate = (template, values) => {
|
|
76
|
+
const table = {
|
|
77
|
+
username: escapeXml(values.username),
|
|
78
|
+
password: escapeXml(Redacted.value(values.password)),
|
|
79
|
+
created: values.created,
|
|
80
|
+
expires: values.expires,
|
|
81
|
+
nonce: values.nonce,
|
|
82
|
+
appliesTo: escapeXml(values.appliesTo)
|
|
83
|
+
};
|
|
84
|
+
return Redacted.make(template.replace(/\{\{\s*([a-zA-Z]+)\s*\}\}/g, (whole, name) => table[name] ?? whole));
|
|
85
|
+
};
|
|
86
|
+
/** The names the template fills, for `check` to compare with what it holds. */
|
|
87
|
+
export const templatePlaceholders = (template) => [...new Set([...template.matchAll(/\{\{\s*([a-zA-Z]+)\s*\}\}/g)].map((m) => m[1] ?? ""))].sort();
|
|
88
|
+
const findElement = (root, local) => {
|
|
89
|
+
if (root.name.local === local) {
|
|
90
|
+
return root;
|
|
91
|
+
}
|
|
92
|
+
for (const child of root.children) {
|
|
93
|
+
if (child._tag === "element") {
|
|
94
|
+
const found = findElement(child, local);
|
|
95
|
+
if (found !== undefined) {
|
|
96
|
+
return found;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
return undefined;
|
|
101
|
+
};
|
|
102
|
+
/**
|
|
103
|
+
* The first element named `local` in `text`, exactly as written (its span),
|
|
104
|
+
* or `undefined`. The text must be the parsed document: spans index it.
|
|
105
|
+
*/
|
|
106
|
+
export const extractElementRaw = (text, root, local) => {
|
|
107
|
+
const found = findElement(root, local);
|
|
108
|
+
return found?.span === undefined ? undefined : text.slice(found.span.start, found.span.end);
|
|
109
|
+
};
|
|
110
|
+
const attributeOf = (element, local) => element.attributes.find((attribute) => attribute.name.local === local)?.value;
|
|
111
|
+
/** `Conditions/@NotOnOrAfter` of a SAML assertion, when it carries one. */
|
|
112
|
+
export const notOnOrAfterOf = (root, tokenLocal) => {
|
|
113
|
+
const assertion = findElement(root, tokenLocal);
|
|
114
|
+
if (assertion === undefined) {
|
|
115
|
+
return undefined;
|
|
116
|
+
}
|
|
117
|
+
const conditions = findElement(assertion, "Conditions");
|
|
118
|
+
const value = conditions === undefined ? undefined : attributeOf(conditions, "NotOnOrAfter");
|
|
119
|
+
if (value === undefined) {
|
|
120
|
+
return undefined;
|
|
121
|
+
}
|
|
122
|
+
const parsed = new Date(value);
|
|
123
|
+
return Number.isNaN(parsed.getTime()) ? undefined : parsed;
|
|
124
|
+
};
|
|
125
|
+
/** One STS call: fill the template, post it, carry the token element verbatim. */
|
|
126
|
+
export const fetchSamlToken = (options) => Effect.gen(function* () {
|
|
127
|
+
const { config } = options;
|
|
128
|
+
const shown = displayUrl(config.endpoint);
|
|
129
|
+
const path = stsTemplatePath(options.service, config);
|
|
130
|
+
const found = yield* options.workspace.discover(path);
|
|
131
|
+
if (!found.includes(path)) {
|
|
132
|
+
return yield* new StsError({
|
|
133
|
+
endpoint: shown,
|
|
134
|
+
detail: `no request template at ${path}; write it with soap-sample "sts init"`
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
const template = yield* options.workspace.read(path);
|
|
138
|
+
const now = options.now ?? (() => new Date());
|
|
139
|
+
const random = options.random ?? ((size) => new Uint8Array(randomBytes(size)));
|
|
140
|
+
const created = now();
|
|
141
|
+
const envelope = fillStsTemplate(template, {
|
|
142
|
+
username: yield* resolvePrincipal(options.secrets, config.auth.user),
|
|
143
|
+
password: yield* resolveText(options.secrets, config.auth.password),
|
|
144
|
+
created: created.toISOString(),
|
|
145
|
+
expires: new Date(created.getTime() + 5 * 60_000).toISOString(),
|
|
146
|
+
nonce: Buffer.from(random(16)).toString("base64"),
|
|
147
|
+
appliesTo: config.appliesTo ?? ""
|
|
148
|
+
});
|
|
149
|
+
const tls = yield* resolveTls(options.secrets, config.tls);
|
|
150
|
+
const proxy = yield* resolveProxy(options.secrets, config.proxy);
|
|
151
|
+
const version = config.soapVersion ?? "1.1";
|
|
152
|
+
const response = yield* options.transport.send({
|
|
153
|
+
method: "POST",
|
|
154
|
+
url: config.endpoint,
|
|
155
|
+
headers: soapHeaders(version, `${WsTrust}/RST/Issue`),
|
|
156
|
+
secretHeaders: [],
|
|
157
|
+
body: envelope,
|
|
158
|
+
...(tls === undefined ? {} : { tls }),
|
|
159
|
+
...(proxy === undefined ? {} : { proxy }),
|
|
160
|
+
timeout: Duration.seconds(config.timeoutSeconds ?? 60)
|
|
161
|
+
});
|
|
162
|
+
const text = yield* decodeXml(response.body, "sts response");
|
|
163
|
+
const root = yield* parseXml(text, { source: "sts response" }).pipe(Effect.mapError(() => new StsError({
|
|
164
|
+
endpoint: shown,
|
|
165
|
+
detail: `HTTP ${response.status} with a body that is not XML`
|
|
166
|
+
})));
|
|
167
|
+
const read = yield* readEnvelope(root).pipe(Effect.option);
|
|
168
|
+
if (read._tag === "Some" && read.value.fault !== undefined) {
|
|
169
|
+
return yield* new StsError({
|
|
170
|
+
endpoint: shown,
|
|
171
|
+
detail: `fault ${read.value.fault.code}: ${read.value.fault.reason}`
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
if (response.status < 200 || response.status >= 300) {
|
|
175
|
+
return yield* new StsError({ endpoint: shown, detail: `HTTP ${response.status}` });
|
|
176
|
+
}
|
|
177
|
+
const element = config.token?.element ?? defaultTokenElement;
|
|
178
|
+
const raw = extractElementRaw(text, root, element);
|
|
179
|
+
if (raw === undefined) {
|
|
180
|
+
return yield* new StsError({
|
|
181
|
+
endpoint: shown,
|
|
182
|
+
detail: `no ${element} element in the response (set sts.token.element to the one it carries)`
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
return {
|
|
186
|
+
token: Redacted.make(raw),
|
|
187
|
+
notOnOrAfter: notOnOrAfterOf(root, element),
|
|
188
|
+
element,
|
|
189
|
+
chars: raw.length
|
|
190
|
+
};
|
|
191
|
+
});
|
|
192
|
+
/**
|
|
193
|
+
* The run's token: fetched on first use, renewed `renewBeforeSeconds`
|
|
194
|
+
* before `NotOnOrAfter` (a token without one lasts the run). Never persisted.
|
|
195
|
+
*/
|
|
196
|
+
export const makeTokenSource = (options) => Effect.gen(function* () {
|
|
197
|
+
const cache = yield* Ref.make(undefined);
|
|
198
|
+
const count = yield* Ref.make(0);
|
|
199
|
+
const now = options.now ?? (() => new Date());
|
|
200
|
+
const margin = (options.config.renewBeforeSeconds ?? 60) * 1000;
|
|
201
|
+
const fresh = (token) => token !== undefined &&
|
|
202
|
+
(token.notOnOrAfter === undefined || token.notOnOrAfter.getTime() - margin > now().getTime());
|
|
203
|
+
return {
|
|
204
|
+
current: Effect.gen(function* () {
|
|
205
|
+
const cached = yield* Ref.get(cache);
|
|
206
|
+
if (fresh(cached)) {
|
|
207
|
+
return cached;
|
|
208
|
+
}
|
|
209
|
+
const token = yield* fetchSamlToken(options);
|
|
210
|
+
yield* Ref.set(cache, token);
|
|
211
|
+
yield* Ref.update(count, (n) => n + 1);
|
|
212
|
+
return token;
|
|
213
|
+
}),
|
|
214
|
+
fetches: Ref.get(count)
|
|
215
|
+
};
|
|
216
|
+
});
|
|
@@ -30,5 +30,12 @@ export const makeDirectoryStubTransport = (directory, catalog) => ({
|
|
|
30
30
|
body: new Uint8Array(bytes),
|
|
31
31
|
elapsedMs: 1
|
|
32
32
|
})), Effect.orElseSucceed(() => stubFault(`stub: no ${operation.name}.xml`)));
|
|
33
|
-
}
|
|
33
|
+
},
|
|
34
|
+
// A stub answers from files: no server, so no chain to show.
|
|
35
|
+
peerChain: (url) => Effect.succeed({
|
|
36
|
+
host: new URL(url).hostname,
|
|
37
|
+
certificates: [],
|
|
38
|
+
trustedBySystem: true,
|
|
39
|
+
authorizationError: undefined
|
|
40
|
+
})
|
|
34
41
|
});
|