-
Notifications
You must be signed in to change notification settings - Fork 2
feat: custom login body and token path for the HTTP adapters #327
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
AhmadRAbuhussein
merged 2 commits into
releases/r10.0
from
hamza/feature/http-login-body-token-path
Sep 27, 2026
Merged
Changes from all commits
Commits
Show all changes
2 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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."); | ||
| } | ||
|
|
||
| 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}'."); | ||
| } | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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) }; | ||
| } | ||
| } | ||
| } |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.