redb.Identity gained a WS-Trust facade: Issue, Validate, Cancel, Renew on the same core routes. The same JWT, the same client registry, WSDL for the generator.
When we built the gRPC facade the claim was this: the logic of redb.Identity lives in the core behind direct-vm://identity-* addresses, and a transport is a thin translator on top of it. The second facade supported the claim. But two transports, both modern, both built on roughly the same assumptions about the world, test it gently. The real test is a third one built on entirely different assumptions.
It now exists. Next to HTTP and gRPC stands WS-Trust: Issue, Validate, Cancel, Renew over SOAP, on the same core routes, with the same issuer and the same token store. A token minted over SOAP is accepted over HTTP and gets the same verdict there.
Who this is for
There are estates where SOAP is not "legacy" but the current norm. Banking back offices, insurance, industrial control, government. They run WCF and CXF buses, their integrations are generated from WSDL, and the team maintaining them knows how to debug an envelope and does not know how to debug an OAuth redirect. Asking such an estate to "just fetch a token over HTTP with a form-encoded body" is asking it to write and maintain code for which it has neither the tooling nor the habits.
WS-Trust is the OAuth of that world. It is older, it is more verbose, it is about XML, but its job is exactly the same: a client arrives with its credentials and leaves with a token. If the authorization server speaks it, the integration stops being a project and becomes a client generated from WSDL.
From the consumer's side it looks like this:
<soap:Envelope xmlns:soap="http://schemas.xmlsoap.org/soap/envelope/">
<soap:Header>
<wsse:Security xmlns:wsse="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-secext-1.0.xsd">
<wsse:UsernameToken>
<wsse:Username>billing-service</wsse:Username>
<wsse:Password>...</wsse:Password>
</wsse:UsernameToken>
</wsse:Security>
</soap:Header>
<soap:Body>
<wst:RequestSecurityToken xmlns:wst="http://docs.oasis-open.org/ws-sx/ws-trust/200512">
<wst:RequestType>http://docs.oasis-open.org/ws-sx/ws-trust/200512/Issue</wst:RequestType>
</wst:RequestSecurityToken>
</soap:Body>
</soap:Envelope>
Not a word about OAuth. Inside it is an ordinary client_credentials call to the same core that serves HTTP.
What was actually added
Four WS-Trust operations, laid onto core routes:
| Operation | Where it goes | What it means |
|---|---|---|
Issue |
identity-token |
mint a token |
Renew |
identity-token |
refresh it |
Validate |
identity-introspect |
is it still alive |
Cancel |
identity-revoke |
revoke it |
Client credentials arrive in a UsernameToken and are translated into client_id and client_secret, the same fields the core takes from everyone else. The facade has no authentication path of its own, and that is the point: a second path would mean a second place where it is decided who gets in.
One detail breaks the shape gRPC taught us. There, every operation is its own address and routing is free. WS-Trust puts all four on one address and tells them apart by the WS-Addressing Action header. Generated clients expect exactly that, so the operation is resolved by parsing the request rather than by the address it arrived on.
What goes into the RSTR
Our ordinary JWT, as it is, in a wsse:BinarySecurityToken with a value type declaring it a JWT.
That was a decision, not a default, and it was made before any code. The alternative was to build a SAML assertion: its own claims model, its own conditions, its own XML signature. The difference is not the writing effort but that the system would gain a second token format, and it would have to be taken through revocation and introspection, which today know one.
We chose to start with JWT and return to SAML when a client turns up that requires it, rather than on an assumption about the market. The cost is stated plainly: some WS-Trust clients expect SAML specifically and will not work with a JWT. For them this facade covers part of the niche, not all of it.
In exchange, what would otherwise have taken weeks works immediately: Validate and Cancel operate on a token minted over SOAP, because it is literally the same object that travels over HTTP.
A refusal arrives as a fault, not as a body
A client told the call succeeded has no reason to look inside the answer. So a refusal from the core has to arrive as a soap:Fault with a code of its own: generated WS-Trust clients branch on the code, and FailedAuthentication is not the same outcome to them as InvalidRequest.
The translation is arranged so as not to become a third independent copy. Reading the core's verdict lives once, in the shared IdentityVerdict; what remains on the SOAP side is rendering that verdict into its own vocabulary:
| Core verdict | Fault |
|---|---|
Unauthenticated, Forbidden
|
wst:FailedAuthentication |
InvalidScope |
wst:InvalidScope |
RateLimited, Unavailable, Timeout, ServerError
|
soap:Server |
| everything else | wst:InvalidRequest |
WS-Trust names considerably fewer outcomes than HTTP does, so several verdicts land on one fault. Where they do, the reason text carries what the code cannot, which is why it is never dropped.
A word on RateLimited. WS-Trust has no code for "wait", and soap:Server is the closer of the two available readings: the refusal is ours, not the caller's, and there is nothing in their request to rewrite.
The real test: one token, two transports, one verdict
Everything else can be attributed to careful implementation. This cannot.
A client is registered over HTTP through /connect/register. A token is fetched over SOAP. That token is then presented back over HTTP on an admin endpoint, and it is accepted. And the other way round: a read-only client is refused a write on both transports.
That is what the word "facade" means here. Not "a SOAP-compatible authorization server alongside the real one", but the same server with a third door.
Boundaries, named up front
No browser flows: authorize, the consent screen, MFA, device confirmation. The reason is not about SOAP; they need a browser, redirects and a cookie session. That niche has its own answer, WS-Federation, and it is filed as a separate decision rather than as an omission.
No DPoP either: RFC 9449 binds its proof to an HTTP method and URL.
No management surface in the first version. gRPC has one, and an estate arriving for WS-Trust usually needs tokens issued, not users administered over SOAP.
TLS is mandatory here, and the facade will not start without it
A UsernameToken in its ordinary form carries the client's password in clear text. An STS on plain HTTP therefore publishes credentials to everyone on the path.
We did not write a warning about that into the log. A warning is read afterwards, a refusal is read beforehand, so the facade simply does not start:
redb.Identity.Soap refuses to start without TLS: WS-Security UsernameToken carries
the client secret in clear text.
There is exactly one way out and it is named explicitly: AllowPlaintext, for a run behind a proxy that already terminates TLS. It is the operator's statement that the wire is protected some other way, not a way around the check. Setting it silently is not possible.
Client certificates are supported at the TLS layer, with modes and thumbprint pinning, the same way as on the gRPC facade.
The WSDL is served on GET
The audience for this facade builds clients with generators, and a generator needs a document. A GET on the same address serves the WSDL with soap:address rewritten to the URL it was fetched from. From there dotnet-svcutil, svcutil or wsimport do their work.
An STS that answers only POST is simply unusable for this audience.
How to see it in ten seconds
There is nothing to install: SOAP is ordinary HTTP with an XML body, so the demo is written against plain Invoke-WebRequest and prints everything that goes out and comes back.
pwsh -File demos/demo_soap_facade.ps1
Ten steps: the WSDL, registering a client over HTTP, Issue over SOAP, Validate, Cancel, a second Validate that now answers negatively, a refusal on a wrong secret with its fault code, an anonymous request, a junk body answered with wst:InvalidRequest, and finally a token minted over SOAP and spent over HTTP.
What covers it
Twenty-eight tests on the facade itself: parsing the RST, translating into core parameters, the fault table, end-to-end runs through a live listener into a real core, and separately a check that the per-IP rate limit actually works behind the facade.
That last one deserves a note. The core keys its rate limiting, its lockout after failed logins and its device metadata on the caller's address. With no address those checks do not fail, they quietly do nothing, which looks exactly like "no abuse". So forwarding the address was not enough; we had to see the limit actually trip behind the facade.
What had to be fixed in the connector itself
The facade is built on redb.Route.Soap, and working on it exposed three holes in the connector. All three are general, none specific to Identity.
A route could not choose its fault code. The consumer took only the text from the exception and always set the code to soap:Server. Every refusal caused by the caller therefore arrived as "we broke", leaving them to wait for us to fix what was theirs to fix.
The consumer could not serve TLS. It registered the listener with a "needs HTTPS" flag but passed neither certificate, nor password, nor client-certificate mode. And the listener DSL could not produce the soaps scheme at all. In that state an HTTPS SOAP endpoint could not be brought up.
A prefixed fault code was written without declaring the prefix. A fault code is a QName in both SOAP versions, and wst:FailedAuthentication with wst unbound reads fine to the eye and does not resolve in a strict client. Strict clients are precisely the ones that branch on the code.
The connector now has fifty-six tests.
Deployment
The facade ships as a separate Tsak module with its own identity.soap context and no compile-time reference to redb.Identity.Core at all: it talks to the core only through direct-vm://.
"identity.soap": {
"IdentityTransport": {
"Soap": {
"Host": "0.0.0.0",
"Port": 5021,
"Path": "/sts",
"Ssl": false,
"AllowPlaintext": true,
"ClientCertificateMode": "NoCertificate",
"Wsdl": true,
"EmitHttpCompatHeaders": true
}
}
}
The Ssl and AllowPlaintext values here are the ones that ship in the box, and they are meant for development. In production you set Ssl: true with a certificate and drop AllowPlaintext. We left them this way deliberately: a module that refuses to start on a fresh machine reads as broken rather than as intended.
The port is separate from the HTTP facade by default. Technically SOAP is ordinary HTTP/1.1 and could share a host. The separation buys something else: WS-Trust can be closed off from the internet while plain OIDC stays open, and the two surfaces get their own firewall rules.
EmitHttpCompatHeaders is on by default and should stay on, for the reason described above about rate limiting.
What comes next
The facade is built, covered by tests and a demo. It has not yet been exercised on a live worker, and until that run we do not call it proven in the field.
After that, as circumstances dictate. The management surface can be added with the same technique already worked out on gRPC. SAML assertions will appear when a client turns up that needs them specifically.
If you have a WCF or CXF estate, the cheapest check takes a minute: bring up the worker and feed your generator the address http://localhost:5021/sts?wsdl. It will build the client, and that client will bring back a real token from the same core that serves your HTTP.
There is a separate write-up on the second transport: the OpenID server gained gRPC alongside HTTP. And another on putting this same server through the official OpenID Foundation conformance suite: running our OpenID server through the official suite.
If this was useful — a ⭐ on GitHub helps others find it.
More of my writing: redbase.app/articles, and on dev.to.

Top comments (0)