Skip to content

Commit

Permalink
chore: catching up
Browse files Browse the repository at this point in the history
  • Loading branch information
SangeetAgarwal committed Jan 26, 2025
1 parent dcdc6a1 commit df99d66
Show file tree
Hide file tree
Showing 6 changed files with 337 additions and 1 deletion.
92 changes: 92 additions & 0 deletions app/data/blog/private-key-jwt-better-client-authentication.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
---
title: Private Key JWTs for better client authentication
date: "2024-06-05"
lastmod: "2024-06-05"
tags: ["authentication", "private key jwts", "security", "JWTs"]
draft: false
summary: Private Key JWTs for better client authentication
layout: PostSimpleLayout
---

## Introduction

Private key JWTs is an attempt to get better at authenticating clients. So, rather than sending
a client secret, the client identifies itself by sending a signed JWT to the authorizing server.

## Problems with client ids and client secrets

Authenticating a client using client id and client secrets is a very rudimentary form of authentication.
IDP cannot definately ensure the client is who it says it is. And, secrets must be stored in a secure location, azure
key vault perhaps, or in the applications settings if your are hosting on azure, but they may also get checked into
source control, so managing secrets is not a trivial task, its cumbersome at best.

## A more resilient solution using Private Key JWTs

Better alternate is to use private key JWTs ^[[JSON Web Token (JWT) Profile for OAuth 2.0 Client Authentication and Authorization Grants](https://datatracker.ietf.org/doc/html/rfc7523)]

JSON signed by a private key results in a JSON Web Token or JWT.

Here's what the decoded JSON Web Token looks like,

```
{
"alg": "RS256",
"kid": "95983758CEA29458A32D90FC436FF2EEE8DE4507",
"typ": "JWT"
}.{
"jti": "125d131e-43dc-49b0-90e6-2b99a4fe79e9",
"sub": "notesmvcappprivatekeyjwt",
"iat": 1699904460,
"nbf": 1699904460,
"exp": 1699904520,
"iss": "notesmvcappprivatekeyjwt",
"aud": "https://localhost:5001/connect/token"
}.[Signature]
```

And, here are the claims

| Claim type | Value | Notes |
| ---------- | --------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| jti | 125d131e-43dc-49b0-90e6-2b99a4fe79e9 | The "jti" (JWT ID) claim provides a unique identifier for the JWT. The identifier value MUST be assigned in a manner that ensures that there is a negligible probability that the same value will be accidentally assigned to a different data object; if the application uses multiple issuers, collisions MUST be prevented among values produced by different issuers as well. The "jti" claim can be used to prevent the JWT from being replayed. The "jti" value is a case-sensitive string. [[RFC 7519](https://datatracker.ietf.org/doc/html/rfc7519), [Section 4.1.7](https://datatracker.ietf.org/doc/html/rfc7519#section-4.1.7)] |
| sub | notesmvcappprivatekeyjwt | The "sub" (subject) claim identifies the principal that is the subject of the JWT. The claims in a JWT are normally statements about the subject. The subject value MUST either be scoped to be locally unique in the context of the issuer or be globally unique. The processing of this claim is generally application specific. The "sub" value is a case-sensitive string containing a StringOrURI value. [[RFC 7519](https://datatracker.ietf.org/doc/html/rfc7519), [Section 4.1.2](https://datatracker.ietf.org/doc/html/rfc7519#section-4.1.2)] |
| iat | Mon Nov 13 2023 14:41:00 GMT-0500 (Eastern Standard Time) | The "iat" (issued at) claim identifies the time at which the JWT was issued. This claim can be used to determine the age of the JWT. [[RFC 7519](https://datatracker.ietf.org/doc/html/rfc7519), [Section 4.1.6](https://datatracker.ietf.org/doc/html/rfc7519#section-4.1.6)] |
| nbf | Mon Nov 13 2023 14:41:00 GMT-0500 (Eastern Standard Time) | The "nbf" (not before) claim identifies the time before which the JWT MUST NOT be accepted for processing. Implementers 6AY provide for some small leeway, usually no more than a few minutes, to account for clock skew. [[RFC 7519](https://datatracker.ietf.org/doc/html/rfc7519), [Section 4.1.5](https://datatracker.ietf.org/doc/html/rfc7519#section-4.1.5) |
| exp | Mon Nov 13 2023 14:42:00 GMT-0500 (Eastern Standard Time) | The "exp" (expiration time) claim identifies the expiration time on or after which the JWT MUST NOT be accepted for processing. Implementers MAY provide for some small leeway, usually no more than a few minutes, to account for clock skew. [[RFC 7519](https://datatracker.ietf.org/doc/html/rfc7519), [Section 4.1.4](https://datatracker.ietf.org/doc/html/rfc7519#section-4.1.4) |
| iss | notesmvcappprivatekeyjwt | The "iss" (issuer) claim identifies the principal that issued the JWT. The processing of this claim is generally application specific. The "iss" value is a case-sensitive string containing a StringOrURI value. [[RFC 7519](https://datatracker.ietf.org/doc/html/rfc7519), [Section 4.1.1](https://datatracker.ietf.org/doc/html/rfc7519#section-4.1.1) |
| aud | https://localhost:5001/connect/token | The "aud" (audience) claim identifies the recipients that the JWT is intended for. Each principal intended to process the JWT MUST identify itself with a value in the audience claim. If the principal processing the claim does not identify itself with a value in the "aud" claim when this claim is present, then the JWT MUST be rejected. [[RFC 7519](https://datatracker.ietf.org/doc/html/rfc7519), [Section 4.1.3](https://datatracker.ietf.org/doc/html/rfc7519#section-4.1.3) |

The client then makes a request for an access token to the authorization server’s token endpoint including the
`client_assertion_type`, `client_assertion` and the `grant_type` parameters.

Here's a sample of the debug output at the IDP's end when the token is validated.

```
Duende.IdentityServer.Validation.TokenRequestValidator
Token request validation success, {"ClientId": "notesmvcappprivatekeyjwt", "ClientName": null,
"GrantType": "authorization_code", "Scopes": null, "AuthorizationCode": "****1A-1",
"RefreshToken": "********", "UserName": null, "AuthenticationContextReferenceClasses": null,
"Tenant": null, "IdP": null, "Raw": {"client_id": "notesmvcappprivatekeyjwt", "code": "***REDACTED***",
"grant_type": "authorization_code", "redirect_uri": "https://localhost:7123/signin-codeflowprivatekeyjwt",
"code_verifier": "ZTm4lZjiYgVGs0HtvZu34lCogNawD6nOlSjRefqavLk",
"client_assertion_type": "urn:ietf:params:oauth:client-assertion-type:jwt-bearer",
"client_assertion": "***REDACTED***"}, "$type": "TokenRequestValidationLog"}
```

`client_assertion` is the signed JWT token and the `client_assertion_type` is `urn:ietf:params:oauth:client-assertion-type:jwt-bearer`

The IDP extracts the JWT from the `client_assertion` and this extracted JWT is then validated.
by the public key that is shared with the IDP ^[This is also known as asymmetric cryptography where pair of related keys, one public and one private are used to encrypt and decrypt a message].
So, when we say _validated by the pubic key_ then what that means is that once the authorization server has extract
the client's assertion which is the signed JWT from the request, then it can verify that the JWT has not been tampered with by using the
public key.

## Improve Manageability

Rather than the IDP keeping the public key of the client, the client can publish its public key to a well known location
and the IDP can then fetch the public key from there.
So, there are two advantages of this

1. the client can rotate its keys without the IDP having to know about it.
2. the IDP can fetch the public key from a well known location rather than having to store it locally so the client is managing the
keypair.
11 changes: 10 additions & 1 deletion app/entry.server.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
* You are free to delete this file if you'd like to, but if you ever want it revealed again, you can run `npx remix reveal` ✨
* For more information, see https://remix.run/docs/en/main/file-conventions/entry.server
*/

import "./fetch-logger.server";
import { PassThrough } from "node:stream";

import type { EntryContext } from "@remix-run/node";
Expand All @@ -12,6 +12,7 @@ import { RemixServer } from "@remix-run/react";
import isbot from "isbot";
import { renderToPipeableStream } from "react-dom/server";
import { otherRootRouteHandlers } from "./sitemapRoutes";
import { handleHostVerification } from "./middleware";

const ABORT_DELAY = 5_000;

Expand All @@ -21,6 +22,14 @@ export default async function handleRequest(
responseHeaders: Headers,
remixContext: EntryContext
) {
// Hostname verification middleware
const hostVerificationResponse = await handleHostVerification(
request,
remixContext
);
if (hostVerificationResponse) {
return hostVerificationResponse;
}
for (const handler of otherRootRouteHandlers) {
const otherRouteResponse = await handler(request, remixContext);
if (otherRouteResponse) return otherRouteResponse;
Expand Down
17 changes: 17 additions & 0 deletions app/fetch-logger.server.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
const originalFetch = global.fetch;

global.fetch = async (input: RequestInfo, init?: RequestInit) => {

Check failure on line 3 in app/fetch-logger.server.ts

View workflow job for this annotation

GitHub Actions / ʦ TypeScript

Type '(input: RequestInfo, init?: RequestInit) => Promise<Response>' is not assignable to type '(input: RequestInfo | URL, init?: RequestInit | undefined) => Promise<Response>'.
const method = init?.method || "GET";
const url = typeof input === "string" ? input : input.url;

console.log(`[fetch] ${method} ${url}`);

try {
const response = await originalFetch(input, init);
console.log(`[fetch] ${method} ${url} - ${response.status}`);
return response;
} catch (error) {
console.error(`[fetch] ${method} ${url} - ERROR`, error);
throw error;
}
};
47 changes: 47 additions & 0 deletions app/middleware.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
// app/middleware.ts

// eslint-disable-next-line @typescript-eslint/no-unused-vars
import { redirect, Response, type EntryContext } from "@remix-run/node";

export async function handleHostVerification(
request: Request,
_remixContext: EntryContext
): Promise<Response | null> {
const allowedHost = "www.makebitbyte.com";
const host = request.headers.get("host")?.toLowerCase();
const url = new URL(request.url);

// Paths that should bypass host verification
const allowedPaths = ["/healthcheck"];

// Allow requests to specified paths regardless of host
if (allowedPaths.includes(url.pathname)) {
return null; // Skip host verification for allowed paths
}

// Allow requests in development mode or from localhost
if (process.env.NODE_ENV === "development" || isLocalHost(host)) {
return null; // Skip host verification
}

if (host !== allowedHost.toLowerCase()) {
// Option 1: Redirect to the custom domain
// url.hostname = allowedHost;
// return redirect(url.toString(), { status: 301 });

// Option 2: Return 404 Not Found
return new Response("Not Found", { status: 404 });
}

// Proceed with the request
return null;
}

function isLocalHost(host: string | undefined): boolean {
if (!host) return false;
return (
host.startsWith("localhost") ||
host.startsWith("127.0.0.1") ||
host.startsWith("[::1]")
);
}
79 changes: 79 additions & 0 deletions app/routes/[rss.xml].tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import type { LoaderFunction } from "@remix-run/server-runtime";
import { getAllFilesFrontMatter } from "~/lib/mdx.server";
import formatDate from "~/lib/utils/formatDate";
import { siteMetadata } from "~/utils/siteMetadata";

export type RssEntry = {
title: string;
link: string;
description: string;
pubDate: string;
author?: string;
guid?: string;
siteUrl?: string;
};

export function generateRss({
description,
entries,
link,
title,
}: {
title: string;
description: string;
link: string;
entries: RssEntry[];
}): string {
return `<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
<channel>
<title>${title}</title>
<description>${description}</description>
<link>${link}</link>
<language>en-us</language>
<ttl>60</ttl>
<atom:link href="${
siteMetadata.siteUrl
}/rss.xml" rel="self" type="application/rss+xml" />
${entries
.map(
(entry) => `
<item>
<title><![CDATA[${entry.title}]]></title>
<description><![CDATA[${entry.description}]]></description>
<pubDate>${entry.pubDate}</pubDate>
<link>${entry.link}</link>
${entry.guid ? `<guid isPermaLink="false">${entry.guid}</guid>` : ""}
</item>`
)
.join("")}
</channel>
</rss>`;
}

export const loader: LoaderFunction = async () => {
const allFrontMatters = await getAllFilesFrontMatter("blog");
allFrontMatters.forEach((frontMatter) => {
return (frontMatter.date = formatDate(frontMatter.date));
});
const feed = generateRss({
title: siteMetadata.title,
description: siteMetadata.description,
link: `${siteMetadata.siteUrl}/blog`,
entries: allFrontMatters.map((post) => ({
description: post.summary,
pubDate: new Date(post.date).toUTCString(),
title: post.title,
link: `${siteMetadata.siteUrl}/${post.slug}`,
guid: `${siteMetadata.siteUrl}/${post.slug}`,
siteUrl: siteMetadata.siteUrl,
})),
});

return new Response(feed, {
headers: {
"Content-Type": "application/xml",
"Cache-Control": "public, max-age=2419200",
},
});
};
92 changes: 92 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

0 comments on commit df99d66

Please sign in to comment.