What I did was this:
I created my own version of a response message which had the bits I needed to create a GenericXmlSecurityToken. This is what's generally returned from a WSTrustChannel, so it seemed like the right thing to do. Thankfully, most of the parameters for a GenericXmlSecurityToken wrapping a JWT are null; I only needed the serialized token, serialized with WriteToken on the JWTSecurityTokenHandler in the service, and the validFrom and validTo values.
Client code:
XmlElement element = document.CreateElement("wsse", "BinarySecurityToken", "http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-secext-1.0.xsd");
element.SetAttribute("ValueType", "urn:ietf:params:oauth:token-type:jwt");
element.SetAttribute("EncodingType", "http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-soap-message-security-1.0#Base64Binary");
UTF8Encoding encoding = new UTF8Encoding();
element.InnerText = Convert.ToBase64String(encoding.GetBytes(jwtToken));
GenericXmlSecurityToken token = new GenericXmlSecurityToken(
element,
null,
validFrom,
validTo,
null,
null,
null);
var binding = new WS2007FederationHttpBinding(WSFederationHttpSecurityMode.TransportWithMessageCredential);
binding.Security.Message.IssuedKeyType = SecurityKeyType.BearerKey;
binding.Security.Message.EstablishSecurityContext = false;
binding.Security.Message.IssuedTokenType = "urn:ietf:params:oauth:token-type:jwt";
var factory2 = new ChannelFactory<IService1>(binding, new EndpointAddress("https://localhost:44300/Service1.svc"));
factory2.Credentials.SupportInteractive = false;
factory2.Credentials.UseIdentityConfiguration = true;
var proxy = factory2.CreateChannelWithIssuedToken(token);
var info = proxy.DoWork();
Relevant bits of the web.config:
The binding:
<ws2007FederationHttpBinding>
<binding>
<security mode="TransportWithMessageCredential">
<message issuedKeyType="BearerKey" establishSecurityContext="false" issuedTokenType="urn:ietf:params:oauth:token-type:jwt"/>
</security>
</binding>
</ws2007FederationHttpBinding>
The identityModel section:
<system.identityModel>
<identityConfiguration>
<audienceUris>
<add value="--audienceUri--"/>
</audienceUris>
<securityTokenHandlers>
<add type="--namespace--.CustomJWTSecurityTokenHandler, --my dll--" />
<securityTokenHandlerConfiguration>
<certificateValidation certificateValidationMode="PeerTrust"/>
</securityTokenHandlerConfiguration>
</securityTokenHandlers>
<issuerNameRegistry>
<trustedIssuers>
<add name="--issuer--" thumbprint="--thumbprint--"/>
</trustedIssuers>
</issuerNameRegistry>
</identityConfiguration>
</system.identityModel>
And the CustomJWTSecurityTokenHandler from this question (only the validIssuer part was required for my scenario): How to configure MIcrosoft JWT with symmetric key?
I haven't seen the issuedTokenType attribute used elsewhere, but I found it was essential to getting my code to work. Without it, I got this error: "MessageSecurityException: Cannot find a token authenticator for the 'Microsoft.IdentityModel.Tokens.JWT.JWTSecurityToken' token type. Tokens of that type cannot be accepted according to current security settings."
This might be overkill as a solution, but I think it minimizes the amount of custom code and centralizes it in places I feel more comfortable with.
Thanks to both user2338856 and leastprivilege for getting me partway there!