diff --git a/SW.Bitween.NativeAdapters/HttpHandler/HttpHandlerInput.cs b/SW.Bitween.NativeAdapters/HttpHandler/HttpHandlerInput.cs
index 79261b3d..91d7de22 100644
--- a/SW.Bitween.NativeAdapters/HttpHandler/HttpHandlerInput.cs
+++ b/SW.Bitween.NativeAdapters/HttpHandler/HttpHandlerInput.cs
@@ -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.")]
diff --git a/SW.Bitween.NativeAdapters/HttpHandler/HttpHandlerModels.cs b/SW.Bitween.NativeAdapters/HttpHandler/HttpHandlerModels.cs
index 623b5059..34c8c0b8 100644
--- a/SW.Bitween.NativeAdapters/HttpHandler/HttpHandlerModels.cs
+++ b/SW.Bitween.NativeAdapters/HttpHandler/HttpHandlerModels.cs
@@ -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; }
diff --git a/SW.Bitween.NativeAdapters/HttpHandler/HttpLogin.cs b/SW.Bitween.NativeAdapters/HttpHandler/HttpLogin.cs
new file mode 100644
index 00000000..f3731a75
--- /dev/null
+++ b/SW.Bitween.NativeAdapters/HttpHandler/HttpLogin.cs
@@ -0,0 +1,73 @@
+using System.Text;
+using DotLiquid;
+using Newtonsoft.Json;
+using Newtonsoft.Json.Linq;
+using SW.PrimitiveTypes;
+
+namespace SW.Bitween.NativeAdapters;
+
+///
+/// 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.
+///
+internal static class HttpLogin
+{
+ public static async Task 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
+ {
+ ["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.");
+ }
+
+ 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}'.");
+ }
+}
diff --git a/SW.Bitween.NativeAdapters/HttpHandler/NativeHttpHandler.cs b/SW.Bitween.NativeAdapters/HttpHandler/NativeHttpHandler.cs
index 92461468..d7e5975c 100644
--- a/SW.Bitween.NativeAdapters/HttpHandler/NativeHttpHandler.cs
+++ b/SW.Bitween.NativeAdapters/HttpHandler/NativeHttpHandler.cs
@@ -45,20 +45,14 @@ public async Task 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(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")
{
diff --git a/SW.Bitween.NativeAdapters/HttpReceiver/HttpReceiverInput.cs b/SW.Bitween.NativeAdapters/HttpReceiver/HttpReceiverInput.cs
index a0c43281..03224d2b 100644
--- a/SW.Bitween.NativeAdapters/HttpReceiver/HttpReceiverInput.cs
+++ b/SW.Bitween.NativeAdapters/HttpReceiver/HttpReceiverInput.cs
@@ -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.")]
diff --git a/SW.Bitween.NativeAdapters/HttpReceiver/NativeHttpReceiver.cs b/SW.Bitween.NativeAdapters/HttpReceiver/NativeHttpReceiver.cs
index 717afe7e..5bce0d59 100644
--- a/SW.Bitween.NativeAdapters/HttpReceiver/NativeHttpReceiver.cs
+++ b/SW.Bitween.NativeAdapters/HttpReceiver/NativeHttpReceiver.cs
@@ -61,21 +61,14 @@ public async Task> 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(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")
{
diff --git a/SW.Bitween.UnitTests/HttpLoginTests.cs b/SW.Bitween.UnitTests/HttpLoginTests.cs
new file mode 100644
index 00000000..d453602a
--- /dev/null
+++ b/SW.Bitween.UnitTests/HttpLoginTests.cs
@@ -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(() => HttpLogin.ReadToken("{\"data\":{}}", "data.token"));
+
+ StringAssert.Contains(ex.Message, "data.token");
+ }
+
+ [TestMethod]
+ public void ReadToken_NonStringTokenIsRejected()
+ {
+ Assert.ThrowsException(() => HttpLogin.ReadToken("{\"data\":{\"token\":{}}}", "data.token"));
+ }
+
+ [TestMethod]
+ public void ReadToken_DefaultNonStringJwtIsRejected()
+ {
+ Assert.ThrowsException(() => HttpLogin.ReadToken("{\"jwt\":123}", null));
+ Assert.ThrowsException(() => HttpLogin.ReadToken("{\"jwt\":{\"value\":\"abc\"}}", null));
+ }
+
+ [TestMethod]
+ public void ReadToken_DefaultWithNoJwtSaysSo()
+ {
+ var ex = Assert.ThrowsException(() => HttpLogin.ReadToken("{\"token\":\"abc\"}", null));
+
+ StringAssert.Contains(ex.Message, "LoginTokenPath");
+ }
+
+ [TestMethod]
+ public void ReadToken_NonJsonResponseSaysSo()
+ {
+ var ex = Assert.ThrowsException(() => HttpLogin.ReadToken("oops", 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(() => 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 SendAsync(HttpRequestMessage request,
+ CancellationToken cancellationToken)
+ {
+ SentBody = request.Content == null ? null : await request.Content.ReadAsStringAsync(cancellationToken);
+ return new HttpResponseMessage(status) { Content = new StringContent(responseBody) };
+ }
+ }
+}
diff --git a/SW.Bitween.Web/ClientApp/e2e/http-login.spec.ts b/SW.Bitween.Web/ClientApp/e2e/http-login.spec.ts
new file mode 100644
index 00000000..efecd4e5
--- /dev/null
+++ b/SW.Bitween.Web/ClientApp/e2e/http-login.spec.ts
@@ -0,0 +1,114 @@
+import { createServer, type IncomingMessage, type Server } from "node:http";
+import type { AddressInfo } from "node:net";
+import { test, expect, type Page } from "@playwright/test";
+import { pickOption, signInAsAdmin } from "./helpers";
+
+/**
+ * The HTTP adapters' "Login" auth, against a partner API that logs in its own way: it wants
+ * `user`/`secret` rather than the default body, and hands the token back under `data.token`
+ * rather than `jwt`. The API is a real server in this process, so the backend has to get the
+ * login right for both the receiver's pull and the handler's push to be let through.
+ */
+
+const TOKEN = "pw-login-token";
+
+interface Hit {
+ method: string;
+ path: string;
+ auth?: string;
+ body: string;
+}
+
+let server: Server;
+let base: string;
+const hits: Hit[] = [];
+
+const readBody = (req: IncomingMessage) =>
+ new Promise((resolve) => {
+ let body = "";
+ req.on("data", (chunk) => (body += chunk));
+ req.on("end", () => resolve(body));
+ });
+
+test.beforeAll(async () => {
+ server = createServer(async (req, res) => {
+ const hit = { method: req.method!, path: req.url!, auth: req.headers.authorization, body: await readBody(req) };
+ hits.push(hit);
+ res.setHeader("Content-Type", "application/json");
+
+ if (hit.path === "/login") {
+ const creds = JSON.parse(hit.body || "{}");
+ if (creds.user !== "pw-user" || creds.secret !== "pw-pass") {
+ res.statusCode = 401;
+ return res.end(JSON.stringify({ error: "bad credentials" }));
+ }
+ return res.end(JSON.stringify({ data: { token: TOKEN } }));
+ }
+ if (hit.auth !== `Bearer ${TOKEN}`) {
+ res.statusCode = 401;
+ return res.end(JSON.stringify({ error: "no token" }));
+ }
+ if (hit.path === "/feed") return res.end(JSON.stringify([{ orderId: "pw-1" }]));
+ return res.end("{}");
+ });
+ await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
+ base = `http://127.0.0.1:${(server.address() as AddressInfo).port}`;
+});
+
+test.afterAll(() => new Promise((resolve) => server.close(() => resolve())));
+
+test.beforeEach(async ({ page }) => signInAsAdmin(page));
+
+/** Fills the login settings the receiver and handler share. Only one stage is open at a time. */
+async function fillLogin(page: Page) {
+ await page.locator("#prop-AuthType").fill("Login");
+ await page.locator("#prop-LoginUrl").fill(`${base}/login`);
+ await page.locator("#prop-LoginUsername").fill("pw-user");
+ await page.locator("#prop-LoginPassword").fill("pw-pass");
+ await page.locator("#prop-LoginBody").fill('{"user":"{{username}}","secret":"{{password}}"}');
+ await page.locator("#prop-LoginTokenPath").fill("data.token");
+}
+
+test("HTTP receiver and handler log in with a custom body and token path", async ({ page }) => {
+ const name = `Playwright HTTP login ${Date.now()}`;
+
+ await page.goto("scheduled-jobs/new");
+ await page.fill("#nj-name", name);
+ await pickOption(page, "Information type", /Shipment order/);
+
+ await pickOption(page, "receiver adapter", "NativeHttpReceiver");
+ await page.locator("#prop-Url").fill(`${base}/feed`);
+ await fillLogin(page);
+ await page.getByRole("button", { name: "Close this step" }).click();
+
+ await page.getByRole("button", { name: /^Delivery/ }).click();
+ await pickOption(page, "handler adapter", "NativeHttpHandler");
+ await page.locator("#prop-Url").fill(`${base}/sink`);
+ await fillLogin(page);
+
+ await page.getByRole("button", { name: "Create job" }).click();
+ await expect(page).toHaveURL(/\/subscriptions\/\d+$/);
+
+ await page.getByRole("button", { name: "Receive now" }).click();
+ await page.getByRole("dialog", { name: "Receive now?" }).getByRole("button", { name: "Receive now" }).click();
+
+ // The receiver pulls, then the handler delivers what it pulled. Both only get past the fake
+ // API's token check if the login used the template and the token was read from data.token.
+ await expect
+ .poll(() => hits.some((h) => h.path === "/sink" && h.method === "POST"), { timeout: 30000 })
+ .toBe(true);
+
+ const logins = hits.filter((h) => h.path === "/login");
+ expect(logins.length).toBeGreaterThanOrEqual(2);
+ for (const login of logins) expect(JSON.parse(login.body)).toEqual({ user: "pw-user", secret: "pw-pass" });
+
+ const feed = hits.find((h) => h.path === "/feed")!;
+ expect(feed.auth).toBe(`Bearer ${TOKEN}`);
+ const sink = hits.find((h) => h.path === "/sink")!;
+ expect(sink.auth).toBe(`Bearer ${TOKEN}`);
+ expect(JSON.parse(sink.body)).toMatchObject({ orderId: "pw-1" });
+
+ await page.getByRole("button", { name: "Delete" }).click();
+ await page.getByRole("button", { name: "Delete subscription" }).click();
+ await expect(page).toHaveURL(/\/subscriptions$/);
+});