Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions SW.Bitween.NativeAdapters/HttpHandler/HttpHandlerInput.cs
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,10 @@ public class HttpHandlerInput
[Secure]
[Description("Password for Basic or OAuth2 password-grant authentication.")]
public string? LoginPassword { get; set; }
[Description("Optional custom login request (Login auth type). Put the real username and password in LoginUsername and LoginPassword, then write {{username}} and {{password}} here where they belong, e.g. {\"email\":\"{{username}}\",\"password\":\"{{password}}\"}. This field is not hidden, so never type a secret into it directly. Leave empty to send the default body.")]
public string? LoginBody { get; set; }
[Description("Optional path to the token in the login response (e.g. token, data.access_token). Empty reads the 'jwt' field.")]
public string? LoginTokenPath { get; set; }

[Required]
[Description("The target HTTP endpoint URL.")]
Expand Down
6 changes: 0 additions & 6 deletions SW.Bitween.NativeAdapters/HttpHandler/HttpHandlerModels.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,6 @@ public class UserLoginModel
public string? Password { get; set; }
}

public class LoginResponse
{
public string? Jwt { get; set; }
public string? Refresh { get; set; }
}

public class OAuth2Response
{
public string? access_token { get; set; }
Expand Down
73 changes: 73 additions & 0 deletions SW.Bitween.NativeAdapters/HttpHandler/HttpLogin.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
using System.Text;
using DotLiquid;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using SW.PrimitiveTypes;

namespace SW.Bitween.NativeAdapters;

/// <summary>
/// The "Login" auth type, shared by the HTTP handler and receiver: post credentials to a login URL
/// and read a bearer token out of the reply. APIs differ in both the body they expect and where
/// they put the token, so each can be set per adapter; left blank, the old fixed shapes apply.
/// </summary>
internal static class HttpLogin
{
public static async Task<string> GetToken(HttpClient client, string loginUrl, string? loginBody,
string? tokenPath, string? username, string? password, object defaultBody)
{
var body = string.IsNullOrWhiteSpace(loginBody)
? JsonConvert.SerializeObject(defaultBody)
: RenderBody(loginBody, username, password);

var response = await client.PostAsync(new Uri(loginUrl),
new StringContent(body, Encoding.UTF8, "application/json"));
var responseBody = await response.Content.ReadAsStringAsync();
if (!response.IsSuccessStatusCode)
throw new SWException(
$"Login to {loginUrl} failed with {(int)response.StatusCode} {response.StatusCode}: {responseBody}");

return ReadToken(responseBody, tokenPath);
}

internal static string RenderBody(string template, string? username, string? password) =>
Template.Parse(template).Render(Hash.FromDictionary(new Dictionary<string, object>
{
["username"] = JsonEscape(username),
["password"] = JsonEscape(password)
}));

// The template is JSON, so a quote or backslash in a credential would otherwise break the body.
private static string JsonEscape(string? value) => JsonConvert.ToString(value ?? string.Empty)[1..^1];

internal static string ReadToken(string responseBody, string? tokenPath)
{
JToken json;
try
{
json = JToken.Parse(responseBody);
}
catch (JsonReaderException)
{
throw new SWException($"The login response is not JSON: {responseBody}");
}

if (string.IsNullOrWhiteSpace(tokenPath))
{
// Matched case-insensitively, as the old fixed deserialisation did.
var jwt = json is JObject obj
&& obj.GetValue("Jwt", StringComparison.OrdinalIgnoreCase) is JValue { Type: JTokenType.String } value
? (string?)value
: null;
return !string.IsNullOrEmpty(jwt)
? jwt
: throw new SWException(
"The login response has no 'jwt' field. Set LoginTokenPath to where the token is.");
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

var path = tokenPath.Trim();
return json.SelectToken(path) is JValue { Type: JTokenType.String } token && !string.IsNullOrEmpty((string?)token)
? (string)token!
: throw new SWException($"The login response has no token at '{path}'.");
}
}
22 changes: 8 additions & 14 deletions SW.Bitween.NativeAdapters/HttpHandler/NativeHttpHandler.cs
Original file line number Diff line number Diff line change
Expand Up @@ -45,20 +45,14 @@ public async Task<XchangeFile> Handle(XchangeFile xchangeFile)
}
else if (_options.AuthType == "Login")
{
string loginJson = JsonConvert.SerializeObject(new UserLoginModel()
{
Email = _options.LoginUsername,
Password = _options.LoginPassword
});
HttpResponseMessage loginResponse = await client.PostAsync(new Uri(_options.LoginUrl!),
new StringContent(loginJson, Encoding.UTF8, "application/json"));
loginResponse.EnsureSuccessStatusCode();
if (loginResponse.StatusCode != HttpStatusCode.OK)
throw new Exception(loginResponse.StatusCode.ToString());
string rs = await loginResponse.Content.ReadAsStringAsync();
LoginResponse? rsDeserialized = JsonConvert.DeserializeObject<LoginResponse>(rs);
client.DefaultRequestHeaders.Authorization =
new AuthenticationHeaderValue("Bearer", rsDeserialized?.Jwt);
string token = await HttpLogin.GetToken(client, _options.LoginUrl!, _options.LoginBody,
_options.LoginTokenPath, _options.LoginUsername, _options.LoginPassword,
new UserLoginModel()
{
Email = _options.LoginUsername,
Password = _options.LoginPassword
});
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
}
else if (_options.AuthType == "OAuth2")
{
Expand Down
4 changes: 4 additions & 0 deletions SW.Bitween.NativeAdapters/HttpReceiver/HttpReceiverInput.cs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,10 @@ public class HttpReceiverInput
[Secure]
[Description("Password for Basic or OAuth2 password-grant authentication.")]
public string? LoginPassword { get; set; }
[Description("Optional custom login request (Login auth type). Put the real username and password in LoginUsername and LoginPassword, then write {{username}} and {{password}} here where they belong, e.g. {\"email\":\"{{username}}\",\"password\":\"{{password}}\"}. This field is not hidden, so never type a secret into it directly. Leave empty to send the default body.")]
public string? LoginBody { get; set; }
[Description("Optional path to the token in the login response (e.g. token, data.access_token). Empty reads the 'jwt' field.")]
public string? LoginTokenPath { get; set; }

[Required]
[Description("The source HTTP endpoint URL to pull data from.")]
Expand Down
23 changes: 8 additions & 15 deletions SW.Bitween.NativeAdapters/HttpReceiver/NativeHttpReceiver.cs
Original file line number Diff line number Diff line change
Expand Up @@ -61,21 +61,14 @@ public async Task<IEnumerable<string>> ListFiles()
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", _options.LoginPassword);
else if (_options.AuthType == "Login")
{
string loginJson = JsonConvert.SerializeObject(new ReceiverUserLoginModel()
{
UserName = _options.LoginUsername,
Password = _options.LoginPassword
});
HttpResponseMessage loginResponse = await client.PostAsync(new Uri(Require(_options.LoginUrl, "LoginUrl")),
new StringContent(loginJson, Encoding.UTF8, "application/json"));
loginResponse.EnsureSuccessStatusCode();
if (loginResponse.StatusCode != HttpStatusCode.OK)
throw new Exception(loginResponse.StatusCode.ToString());
string rs = await loginResponse.Content.ReadAsStringAsync();
LoginResponse? rsDeserialized = JsonConvert.DeserializeObject<LoginResponse>(rs);
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer",
rsDeserialized?.Jwt ?? throw new SWException(
"The login endpoint did not return a JSON body carrying a 'jwt'."));
string token = await HttpLogin.GetToken(client, Require(_options.LoginUrl, "LoginUrl"), _options.LoginBody,
_options.LoginTokenPath, _options.LoginUsername, _options.LoginPassword,
new ReceiverUserLoginModel()
{
UserName = _options.LoginUsername,
Password = _options.LoginPassword
});
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
}
else if (_options.AuthType == "OAuth2")
{
Expand Down
134 changes: 134 additions & 0 deletions SW.Bitween.UnitTests/HttpLoginTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
using System;
using System.Net;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using Newtonsoft.Json.Linq;
using SW.Bitween.NativeAdapters;
using SW.PrimitiveTypes;

namespace SW.Bitween.UnitTests;

[TestClass]
public class HttpLoginTests
{
// ─── Login body ─────────────────────────────────────────────────────────────

[TestMethod]
public void RenderBody_FillsUsernameAndPassword()
{
var body = HttpLogin.RenderBody("{\"email\":\"{{username}}\",\"password\":\"{{password}}\"}", "a@b.com", "secret");

Assert.AreEqual("{\"email\":\"a@b.com\",\"password\":\"secret\"}", body);
}

[TestMethod]
public void RenderBody_EscapesCredentialsSoTheBodyStaysValidJson()
{
var body = HttpLogin.RenderBody("{\"password\":\"{{password}}\"}", "u", "pa\"ss\\word");

Assert.AreEqual("pa\"ss\\word", JObject.Parse(body)["password"]!.ToString());
}

[TestMethod]
public async Task GetToken_SendsTheDefaultBodyWhenNoTemplateIsSet()
{
var handler = new FakeHandler(HttpStatusCode.OK, "{\"jwt\":\"abc\"}");

await HttpLogin.GetToken(new HttpClient(handler), "https://api.test/login", null, null, "u", "p",
new UserLoginModel { Email = "u", Password = "p" });

Assert.AreEqual("{\"Email\":\"u\",\"Password\":\"p\"}", handler.SentBody);
}

[TestMethod]
public async Task GetToken_SendsTheTemplateWhenSet()
{
var handler = new FakeHandler(HttpStatusCode.OK, "{\"jwt\":\"abc\"}");

await HttpLogin.GetToken(new HttpClient(handler), "https://api.test/login", "{\"user\":\"{{username}}\"}",
null, "u", "p", new UserLoginModel());

Assert.AreEqual("{\"user\":\"u\"}", handler.SentBody);
}

// ─── Token path ─────────────────────────────────────────────────────────────

[TestMethod]
public void ReadToken_DefaultReadsJwtInAnyCase()
{
Assert.AreEqual("abc", HttpLogin.ReadToken("{\"Jwt\":\"abc\"}", null));
Assert.AreEqual("abc", HttpLogin.ReadToken("{\"jwt\":\"abc\"}", " "));
}

[TestMethod]
public void ReadToken_FollowsTheConfiguredPath()
{
Assert.AreEqual("abc", HttpLogin.ReadToken("{\"access_token\":\"abc\"}", "access_token"));
Assert.AreEqual("abc", HttpLogin.ReadToken("{\"data\":{\"token\":\"abc\"}}", " data.token "));
}

[TestMethod]
public void ReadToken_MissingTokenNamesThePath()
{
var ex = Assert.ThrowsException<SWException>(() => HttpLogin.ReadToken("{\"data\":{}}", "data.token"));

StringAssert.Contains(ex.Message, "data.token");
}

[TestMethod]
public void ReadToken_NonStringTokenIsRejected()
{
Assert.ThrowsException<SWException>(() => HttpLogin.ReadToken("{\"data\":{\"token\":{}}}", "data.token"));
}

[TestMethod]
public void ReadToken_DefaultNonStringJwtIsRejected()
{
Assert.ThrowsException<SWException>(() => HttpLogin.ReadToken("{\"jwt\":123}", null));
Assert.ThrowsException<SWException>(() => HttpLogin.ReadToken("{\"jwt\":{\"value\":\"abc\"}}", null));
}

[TestMethod]
public void ReadToken_DefaultWithNoJwtSaysSo()
{
var ex = Assert.ThrowsException<SWException>(() => HttpLogin.ReadToken("{\"token\":\"abc\"}", null));

StringAssert.Contains(ex.Message, "LoginTokenPath");
}

[TestMethod]
public void ReadToken_NonJsonResponseSaysSo()
{
var ex = Assert.ThrowsException<SWException>(() => HttpLogin.ReadToken("<html>oops</html>", null));

StringAssert.Contains(ex.Message, "not JSON");
}

// ─── Failures ───────────────────────────────────────────────────────────────

[TestMethod]
public async Task GetToken_FailedLoginIncludesStatusAndBody()
{
var handler = new FakeHandler(HttpStatusCode.Unauthorized, "bad password");

var ex = await Assert.ThrowsExceptionAsync<SWException>(() => HttpLogin.GetToken(new HttpClient(handler),
"https://api.test/login", null, null, "u", "p", new UserLoginModel()));

StringAssert.Contains(ex.Message, "401");
StringAssert.Contains(ex.Message, "bad password");
}

private class FakeHandler(HttpStatusCode status, string responseBody) : HttpMessageHandler
{
public string SentBody { get; private set; }

protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request,
CancellationToken cancellationToken)
{
SentBody = request.Content == null ? null : await request.Content.ReadAsStringAsync(cancellationToken);
return new HttpResponseMessage(status) { Content = new StringContent(responseBody) };
}
}
}
Loading
Loading