-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
1 parent
dcdc6a1
commit df99d66
Showing
6 changed files
with
337 additions
and
1 deletion.
There are no files selected for viewing
92 changes: 92 additions & 0 deletions
92
app/data/blog/private-key-jwt-better-client-authentication.mdx
This file contains 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,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. |
This file contains 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 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,17 @@ | ||
const originalFetch = global.fetch; | ||
|
||
global.fetch = async (input: RequestInfo, init?: RequestInit) => { | ||
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; | ||
} | ||
}; |
This file contains 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,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]") | ||
); | ||
} |
This file contains 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,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", | ||
}, | ||
}); | ||
}; |
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
Oops, something went wrong.