From 5f90003dd66b9e050f172033a1f484a51128e514 Mon Sep 17 00:00:00 2001 From: Hamza Alqurneh Date: Sun, 27 Sep 2026 11:51:45 +0300 Subject: [PATCH 1/2] feat: custom login body and token path for the HTTP adapters --- .../HttpHandler/HttpHandlerInput.cs | 4 + .../HttpHandler/HttpHandlerModels.cs | 6 - .../HttpHandler/HttpLogin.cs | 72 ++++++++++ .../HttpHandler/NativeHttpHandler.cs | 22 ++- .../HttpReceiver/HttpReceiverInput.cs | 4 + .../HttpReceiver/NativeHttpReceiver.cs | 23 ++-- SW.Bitween.UnitTests/HttpLoginTests.cs | 127 ++++++++++++++++++ .../ClientApp/e2e/http-login.spec.ts | 114 ++++++++++++++++ 8 files changed, 337 insertions(+), 35 deletions(-) create mode 100644 SW.Bitween.NativeAdapters/HttpHandler/HttpLogin.cs create mode 100644 SW.Bitween.UnitTests/HttpLoginTests.cs create mode 100644 SW.Bitween.Web/ClientApp/e2e/http-login.spec.ts 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..bd0602b1 --- /dev/null +++ b/SW.Bitween.NativeAdapters/HttpHandler/HttpLogin.cs @@ -0,0 +1,72 @@ +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)?.ToString() + : 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..e6b0416e --- /dev/null +++ b/SW.Bitween.UnitTests/HttpLoginTests.cs @@ -0,0 +1,127 @@ +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_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$/); +}); From d42d12a7c02c1ca12a7b9a9159dacf6423a46e0b Mon Sep 17 00:00:00 2001 From: Hamza Alqurneh Date: Sun, 27 Sep 2026 12:46:17 +0300 Subject: [PATCH 2/2] fix: reject a non-string jwt in the login reply --- SW.Bitween.NativeAdapters/HttpHandler/HttpLogin.cs | 3 ++- SW.Bitween.UnitTests/HttpLoginTests.cs | 9 ++++++++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/SW.Bitween.NativeAdapters/HttpHandler/HttpLogin.cs b/SW.Bitween.NativeAdapters/HttpHandler/HttpLogin.cs index bd0602b1..f3731a75 100644 --- a/SW.Bitween.NativeAdapters/HttpHandler/HttpLogin.cs +++ b/SW.Bitween.NativeAdapters/HttpHandler/HttpLogin.cs @@ -56,7 +56,8 @@ internal static string ReadToken(string responseBody, string? tokenPath) { // Matched case-insensitively, as the old fixed deserialisation did. var jwt = json is JObject obj - ? obj.GetValue("Jwt", StringComparison.OrdinalIgnoreCase)?.ToString() + && obj.GetValue("Jwt", StringComparison.OrdinalIgnoreCase) is JValue { Type: JTokenType.String } value + ? (string?)value : null; return !string.IsNullOrEmpty(jwt) ? jwt diff --git a/SW.Bitween.UnitTests/HttpLoginTests.cs b/SW.Bitween.UnitTests/HttpLoginTests.cs index e6b0416e..d453602a 100644 --- a/SW.Bitween.UnitTests/HttpLoginTests.cs +++ b/SW.Bitween.UnitTests/HttpLoginTests.cs @@ -83,6 +83,13 @@ 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() { @@ -115,7 +122,7 @@ public async Task GetToken_FailedLoginIncludesStatusAndBody() private class FakeHandler(HttpStatusCode status, string responseBody) : HttpMessageHandler { - public string? SentBody { get; private set; } + public string SentBody { get; private set; } protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)