Skip to content

Commit

Permalink
Merge pull request #16 from khalidabuhakmeh/htmx-boost-token
Browse files Browse the repository at this point in the history
Support updating the antiforgery token when a boost occurs
  • Loading branch information
khalidabuhakmeh authored May 31, 2023
2 parents 62d9da4 + c526ae6 commit d3d6e96
Show file tree
Hide file tree
Showing 6 changed files with 140 additions and 39 deletions.
89 changes: 68 additions & 21 deletions Readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -139,30 +139,46 @@ The resulting HTML will be.

### HTMX and Anti-forgery Tokens

You can set the attribute `includeAspNetAntiforgerToken` on the `htmx-config` element. Then you'll need to include this additional JavaScript in your web application.
You can set the attribute `includeAspNetAntiforgerToken` on the `htmx-config` element. Then you'll need to include this additional JavaScript in your web application. We include the attribute `__htmx_antiforgery` to track the event listener was added already. This keeps us from accidentally re-registering the event listener.

```javascript
document.addEventListener("htmx:configRequest", (evt) => {
let httpVerb = evt.detail.verb.toUpperCase();
if (httpVerb === 'GET') return;

let antiForgery = htmx.config.antiForgery;

if (antiForgery) {

// already specified on form, short circuit
if (evt.detail.parameters[antiForgery.formFieldName])
return;

if (antiForgery.headerName) {
evt.detail.headers[antiForgery.headerName]
= antiForgery.requestToken;
} else {
evt.detail.parameters[antiForgery.formFieldName]
= antiForgery.requestToken;
if (!document.body.attributes.__htmx_antiforgery) {
document.addEventListener("htmx:configRequest", evt => {
let httpVerb = evt.detail.verb.toUpperCase();
if (httpVerb === 'GET') return;
let antiForgery = htmx.config.antiForgery;
if (antiForgery) {
// already specified on form, short circuit
if (evt.detail.parameters[antiForgery.formFieldName])
return;

if (antiForgery.headerName) {
evt.detail.headers[antiForgery.headerName]
= antiForgery.requestToken;
} else {
evt.detail.parameters[antiForgery.formFieldName]
= antiForgery.requestToken;
}
}
}
});
});
document.addEventListener("htmx:afterOnLoad", evt => {
if (evt.detail.boosted) {
const parser = new DOMParser();
const html = parser.parseFromString(evt.detail.xhr.responseText, 'text/html');
const selector = 'meta[name=htmx-config]';
const config = html.querySelector(selector);
if (config) {
const current = document.querySelector(selector);
// only change the anti-forgery token
const key = 'antiForgery';
htmx.config[key] = JSON.parse(config.attributes['content'].value)[key];
// update DOM, probably not necessary, but for sanity's sake
current.replaceWith(config);
}
}
});
document.body.attributes.__htmx_antiforgery = true;
}
```

You can access the snippet in two ways. The first is to use the `HtmxSnippet` static class in your views.
Expand All @@ -183,6 +199,37 @@ This html helper will result in a `<script>` tag along with the previously menti

Note that if the `hx-[get|post|put]` attribute is on a `<form ..>` tag, the ASP.NET Tag Helpers will add the Anti-forgery Token as an `input` element and you do not need to further configure your requests as above. You could also use [`hx-include`](https://htmx.org/attributes/hx-include/) pointing to a form, but this all comes down to a matter of preference.

Additionally, and **the recommended approach** is to use the `HtmxAntiforgeryScriptEndpoint`, which will let you map the JavaScript file to a specific endpoint, and by default it will be `_htmx/antiforgery.js`.

```c#
app.UseAuthorization();
// registered here
app.MapHtmxAntiforgeryScript();
app.MapRazorPages();
app.MapControllers();
```

You can now configure this endpoint with caching, authentication, etc. More importantly, you can use the script in your `head` tag now by applying the `defer` tag, which is preferred to having JavaScript at the end of a `body` element.

```html
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<meta
name="htmx-config"
historyCacheSize="20"
indicatorClass="htmx-indicator"
includeAspNetAntiforgeryToken="true"/>
<title>@ViewData["Title"] - Htmx.Sample</title>
<link rel="stylesheet" href="~/lib/bootstrap/dist/css/bootstrap.min.css"/>
<link rel="stylesheet" href="~/css/site.css" asp-append-version="true"/>
<script src="~/lib/jquery/dist/jquery.min.js" defer></script>
<script src="~/lib/bootstrap/dist/js/bootstrap.bundle.min.js" defer></script>
<script src="https://unpkg.com/htmx.org@@1.9.2" defer></script>
<!-- this uses the static value in a script tag -->
<script src="@HtmxAntiforgeryScriptEndpoints.Path" defer></script>
</head>
```

## License

Expand Down
2 changes: 2 additions & 0 deletions src/Htmx.TagHelpers/HtmlExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ public static class HtmlExtensions
/// </summary>
/// <remarks>
/// Note: This includes wrapping script tags.To get the JavaScript string use <see cref="HtmxSnippets.AntiforgeryJavaScript">HtmxSnippets.AntiforgeryJavaScript</see>.
///
/// You may also want to consider using the <see cref="HtmxAntiforgeryScriptEndpoints"/> instead of this approach.
/// </remarks>
/// <param name="helper">An instance of the HTML Helper interface</param>
/// <returns>HTML Content with JavaScript tag</returns>
Expand Down
37 changes: 37 additions & 0 deletions src/Htmx.TagHelpers/HtmxAntiforgeryScriptEndpoints.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;

namespace Htmx.TagHelpers;

public static class HtmxAntiforgeryScriptEndpoints
{
/// <summary>
/// The path to the antiforgery script that is used from HTML
/// </summary>
public static string Path { get; private set; } = "_htmx/antiforgery.js";

/// <summary>
/// Register an endpoint that responds with the HTMX antiforgery script.<br/>
/// IMPORTANT: Remember to add the following script tag to your _Layout.cshtml or Razor view:
/// <![CDATA[
/// <script src="@HtmxAntiforgeryScriptEndpoints.Path" defer></script>
/// ]]>
/// </summary>
/// <param name="builder">Endpoint builder</param>
/// <param name="path">The path to the antiforgery script</param>
/// <returns>The registered endpoint (Use <seealso cref="Path"/> to reference endpoint)</returns>
public static IEndpointConventionBuilder MapHtmxAntiforgeryScript(
this IEndpointRouteBuilder builder,
string? path = null)
{
// set Path globally for access
Path = path ?? Path;

return builder.MapGet(Path, async ctx =>
{
ctx.Response.ContentType = "text/javascript";
await ctx.Response.WriteAsync(HtmxSnippets.AntiforgeryJavaScript);
});
}
}
21 changes: 17 additions & 4 deletions src/Htmx.TagHelpers/JavaScript/antiforgerySnippet.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,9 @@ if (!document.body.attributes.__htmx_antiforgery) {
document.addEventListener("htmx:configRequest", evt => {
let httpVerb = evt.detail.verb.toUpperCase();
if (httpVerb === 'GET') return;

let antiForgery = htmx.config.antiForgery;

if (antiForgery) {

// already specified on form, short circuit
// already specified on the form, short circuit
if (evt.detail.parameters[antiForgery.formFieldName])
return;

Expand All @@ -20,5 +17,21 @@ if (!document.body.attributes.__htmx_antiforgery) {
}
}
});
document.addEventListener("htmx:afterOnLoad", evt => {
if (evt.detail.boosted) {
const parser = new DOMParser();
const html = parser.parseFromString(evt.detail.xhr.responseText, 'text/html');
const selector = 'meta[name=htmx-config]';
const config = html.querySelector(selector);
if (config) {
const current = document.querySelector(selector);
// only change the anti-forgery token
const key = 'antiForgery';
htmx.config[key] = JSON.parse(config.attributes['content'].value)[key];
// update DOM, probably not necessary, but for sanity's sake
current.replaceWith(config);
}
}
});
document.body.attributes.__htmx_antiforgery = true;
}
26 changes: 13 additions & 13 deletions test/Sample/Pages/Shared/_Layout.cshtml
Original file line number Diff line number Diff line change
Expand Up @@ -3,26 +3,31 @@
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<meta name="htmx-config"
historyCacheSize="20"
indicatorClass="htmx-indicator"
includeAspNetAntiforgeryToken="true"
/>
<meta
name="htmx-config"
historyCacheSize="20"
indicatorClass="htmx-indicator"
includeAspNetAntiforgeryToken="true"/>
<title>@ViewData["Title"] - Htmx.Sample</title>
<link rel="stylesheet" href="~/lib/bootstrap/dist/css/bootstrap.min.css"/>
<link rel="stylesheet" href="~/css/site.css" asp-append-version="true"/>
<script src="~/lib/jquery/dist/jquery.min.js" defer></script>
<script src="~/lib/bootstrap/dist/js/bootstrap.bundle.min.js" defer></script>
<script src="https://unpkg.com/htmx.org@@1.9.2" defer></script>
<script src="@HtmxAntiforgeryScriptEndpoints.Path" defer></script>
</head>
<body hx-boost="true">
<body>
<header>
<nav class="navbar navbar-expand-sm navbar-toggleable-sm navbar-light bg-white border-bottom box-shadow mb-3">
<nav
class="navbar navbar-expand-sm navbar-toggleable-sm navbar-light bg-white border-bottom box-shadow mb-3">
<div class="container">
<a class="navbar-brand" asp-area="" asp-page="/Index">Htmx.Sample</a>
<button class="navbar-toggler" type="button" data-toggle="collapse" data-target=".navbar-collapse" aria-controls="navbarSupportedContent"
aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<div class="navbar-collapse collapse d-sm-inline-flex justify-content-between">
<ul class="navbar-nav flex-grow-1">
<ul hx-boost="true" class="navbar-nav flex-grow-1">
<li class="nav-item">
<a class="nav-link text-dark" asp-area="" asp-page="/Index">Home</a>
</li>
Expand All @@ -46,11 +51,6 @@
</div>
</footer>

<script src="~/lib/jquery/dist/jquery.min.js"></script>
<script src="~/lib/bootstrap/dist/js/bootstrap.bundle.min.js"></script>
<script src="https://unpkg.com/[email protected]"></script>
@Html.HtmxAntiforgeryScript()

@await RenderSectionAsync("Scripts", required: false)
</body>
</html>
4 changes: 3 additions & 1 deletion test/Sample/Program.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
using Htmx.TagHelpers;

var builder = WebApplication.CreateBuilder(args);

// Add services to the container.
Expand All @@ -20,7 +22,7 @@
app.UseRouting();

app.UseAuthorization();

app.MapHtmxAntiforgeryScript();
app.MapRazorPages();
app.MapControllers();

Expand Down

0 comments on commit d3d6e96

Please sign in to comment.