Skip to content
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

feat(hono/jwk): JWK Auth Middleware #3826

Merged
merged 34 commits into from
Feb 6, 2025
Merged

feat(hono/jwk): JWK Auth Middleware #3826

merged 34 commits into from
Feb 6, 2025

Conversation

Beyondo
Copy link
Contributor

@Beyondo Beyondo commented Jan 13, 2025

Hello, I recently needed a JWK middleware for my projects, but I figured contributing it to hono has the potential to save me and others a lot of time.

Middleware Features:

  • Set options.keys to a static array of public keys HonoJsonWebKey[] in code.
  • Set options.keys to an async function that returns a Promise<HonoJsonWebKey[]> for flexibility
  • Set options.jwks_uri to fetch JWKs from a URI, after which it appends those fetched keys to provided keys if any
  • Set an optional init parameter (only used for jwks_uri)—useful if your host supports caching through custom init options.

Added extra:

  • Added JwtHeaderRequiresKid exception. Since the middleware requires presence of a kid field in the header in order to select the correct key.
  • Added Jwt.verifyFromJwks util function (batteries included).

Addressed issues:

Other code changes:

  • Typescript-extended JsonWebKey to have kid?: string (This is a standard: https://datatracker.ietf.org/doc/html/rfc7515#section-4.1.4)
  • Added kid?: string to TokenHeader
  • Fixed a bug in Jwt.sign(payload, privateKey, alg) where privateKey.alg was always ignored and only alg parameter was considered.

Suggestion: Removal of the default 'HS256' value from Jwt.sign and make alg optional, so that if neither privateKey.alg nor alg is defined, throw an error. My 'philosophy' for this is that it would be misleading for hono to display = 'HS256' when the privateKey can internally have a different algorithm, such as when it is of type JsonWebKey or CryptoKey (Though we only care about JsonWebKey here). Making alg a "fallback" explicit parameter could be better.

Example Usage:

Using jwks_uri

import { jwk } from "hono/jwk";
app.use("/backend-api/*", jwk({ jwks_uri: "https://example-backend.hono.dev/.well-known/jwks.json" }));

Using jwks_uri and an optional init

import { jwk } from "hono/jwk";
app.use("/backend-api/*", jwk({
  jwks_uri: "https://example.hono.dev/.well-known/jwks.json"
}, {
  headers: {
    "My-Custom-Header": "Hello Hono!"
  },
  cf: { cacheTtl: 3600, cacheEverything: true } // Only for Cloudflare Workers
}));

Using keys from an array

[
  {
    "kid": "VpNx_Ar8pXsh50UjqBD_XtYLj4k9Z72iakeOQFJYIpg",
    "kty": "RSA",
    "use": "sig",
    "alg": "RS256",
    "e": "AQAB",
    "n": "4anIpNGZ-C5rJfRty8qQt8wTheFDj9wchPA..."
  },
  {
    "kid": "Rw6zp1oTEbQ9qUIFnhHGadtstXhKYrCmkqgkNEG8uUc",
    "kty": "RSA",
    "use": "sig",
    "alg": "RS256",
    "e": "AQAB",
    "n": "vKetN8i4_ORAWAUVR1pCjVMFkNzwC1Qjy..."
  }
]
import keys from './keys.json';
import { jwk } from "hono/jwk";
app.use("/backend-api/*", jwk({ keys: keys }));

Using keys as an async function (custom logic / caching)

$router.use("/auth/*", jwk({
  keys: async () => {
    /* ... Custom caching logic ... */
    return cached_response.keys;
  }
}));

Tests:

I adapted a lot from JWT's own units tests to make sure some basic JWT validation exists, then added the actual validation for the JWK middleware's own functionality which is mostly: (1) getting keys through various methods, (2) selecting the appropriate JWK based on kid (without fully decoding the JWT—only the header), and then (3) verifying using the utility Jwt.verify. Step 2 and 3 are done by Jwt.verifyFromJwks.

JWK Middleware Test

hono> npx src/middleware/jwk/index.test.ts

 DEV  v2.1.1 V:/Repos/hono
      Coverage enabled with v8

 ✓ src/middleware/jwk/index.test.ts (16)
   ✓ JWK (16)
     ✓ Credentials in header (10)
       ✓ Should not authorize requests with missing access token
       ✓ Should authorize from a static array passed to options.keys (key 1)
       ✓ Should authorize from a static array passed to options.keys (key 2)
       ✓ Should authorize with Unicode payload from a static array passed to options.keys
       ✓ Should authorize from a function passed to options.keys
       ✓ Should authorize from a URI remotely fetched from options.jwks_uri
       ✓ Should not authorize requests with invalid Unicode payload in header
       ✓ Should not authorize requests with malformed token structure in header
       ✓ Should not authorize requests without authorization in nested JWK middleware
       ✓ Should authorize requests with authorization in nested JWK middleware
     ✓ Credentials in cookie (5)
       ✓ Should not authorize requests with missing access token
       ✓ Should authorize from a static array passed to options.keys
       ✓ Should authorize with Unicode payload from a static array passed to options.keys
       ✓ Should not authorize requests with invalid Unicode payload in cookie
       ✓ Should not authorize requests with malformed token structure in cookie
     ✓ Error handling with `cause` (1)
       ✓ Should not authorize

 Test Files  1 passed (1)
      Tests  16 passed (16)

Full Test

hono> npm test
...
...
...
 Test Files  9 passed (9)
      Tests  415 passed | 3 skipped (418)

  • [✅] Add tests (src/middleware/jwk/index.test.ts)
  • [✅] Run tests (Above)
  • [✅] bun run format:fix && bun run lint:fix to format the code (Done)
  • [✅] Add TSDoc/JSDoc to document the code (src/middleware/jwk/jwk.ts)

Feel free to contribute more unit tests, extend and/or modify for the better.

…yAlg fallback, add decodeHeaders utility

- Added "kid" (Key ID) for TokenHeader
- Fixed Jwt.sign() ignoring privateKey.alg
- Renamed `alg` parameter to `KEYAlg` to differentiate between privateKey.alg
- Added utility function `decodeHeaders` to decode only JWT headers
Hono JWK Middleware main features:
- Ability to provide a list of public JWKs to the keys parameter as a simple javascript array []
- Ability to provide a URL in the jwks_uri parameter to fetch keys from + an optional RequestInit (useful for caching if your cloud provider has a modified fetch that supports it, or if you simply want to modify the request)
- Ability to provide an async function that returns an array to the keys parameter instead of a direct array, so that it is possible to implement own custom caching layer without a separate middleware
- Allows setting a keys directory for multi-key auth systems
- Allows auth endpoints to be always updated with the Auth provider's public JWKs directory (often `.well-known/jwks.json`) which makes key rotations without disruptions possible

Todo:
- More tests.
Added /auth-keys-fn/* & /.well-known/jwks.json testing endpoints to the router.
Copy link

codecov bot commented Jan 13, 2025

Codecov Report

Attention: Patch coverage is 91.37931% with 15 lines in your changes missing coverage. Please review.

Project coverage is 90.79%. Comparing base (2ead4d8) to head (c9919a0).
Report is 29 commits behind head on next.

Files with missing lines Patch % Lines
src/utils/jwt/jwt.ts 87.30% 8 Missing ⚠️
src/middleware/jwk/jwk.ts 96.07% 4 Missing ⚠️
src/utils/jwt/types.ts 50.00% 3 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             next    #3826      +/-   ##
==========================================
- Coverage   91.71%   90.79%   -0.92%     
==========================================
  Files         160      164       +4     
  Lines       10195    10434     +239     
  Branches     2885     3061     +176     
==========================================
+ Hits         9350     9474     +124     
- Misses        844      959     +115     
  Partials        1        1              

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

@Beyondo
Copy link
Contributor Author

Beyondo commented Jan 14, 2025

Note that the test code jwk/index.test.ts was 519 lines so it inflated the actual aggregated changes of this PR, + jsdoc comments, and + the keys.test.json file and so on. The core changes are probably < 200 lines and thus much more mangeable to review.

I removed some commented out tests to give a clearer picture (Now at 307 lines of test code). But they did remind me—we need some signed cookie unit tests if we're gonna make sure to support everything in jwt/index.test.ts too. Though I don't think it's exactly a priority and could be implemented later.

@yusukebe
Copy link
Member

Hi @Beyondo

Thanks! I'll check this later.

@Beyondo
Copy link
Contributor Author

Beyondo commented Jan 14, 2025

@yusukebe No problem! I originally named the new interface ExtendedJsonWebKey, though I do think HonoJsonWebKey might also work better since the only addition is the optional kid?, making it function just like the normal JsonWebKey if needed, so we might need some opinion on this.

Take your time! I'm using the fork temporarily, so no rush at all.

@yusukebe
Copy link
Member

Hey @Code-Hex ! I'll review this later, but can you take a look at it too?

@Code-Hex
Copy link
Contributor

@yusukebe I'm sorry I missed this PR.
I will check tomorrow 🙏

Copy link
Contributor

@Code-Hex Code-Hex left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It was mostly LGTM!

src/middleware/jwk/index.ts Outdated Show resolved Hide resolved
src/middleware/jwk/jwk.ts Outdated Show resolved Hide resolved
src/middleware/jwk/jwk.ts Outdated Show resolved Hide resolved
Also removed redundant "import type {} from '../..'" from 3 different files:
- jwk/index.ts
- jwt/index.ts
- request-id/index.ts
Copy link
Contributor

@Code-Hex Code-Hex left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

@yusukebe

@yusukebe
Copy link
Member

@Code-Hex Thank you for the review!

Hi, @Beyondo, Almost all are good, but the test coverage is decreased. Of course, it should not be 100%, but is there any chance to add more tests?

@Beyondo
Copy link
Contributor Author

Beyondo commented Jan 31, 2025

Hey, @yusukebe ! I improved test coverage by around +15% focusing on testing the important parts, such as signed cookies and missing kid exception and a few others, while the rest of unconvered lines are fairly trivial just by looking at them but also a bit tedious to write tests for; I will cover more of them as soon as I got time

@yusukebe
Copy link
Member

yusukebe commented Feb 2, 2025

@Beyondo

Thank you so much! If it's ready, please ping me. I'll review it.

Note: Moved more code from `hono/jwk` to the `verifyFromJwks` function for backends that require JWK verification logic beyond just a middleware
@Beyondo
Copy link
Contributor Author

Beyondo commented Feb 2, 2025

@yusukebe Yo, everything, at least JWK-related, is covered & all hono tests pass.

I think it should be decent enough for review now

src/utils/jwt/jws.ts Show resolved Hide resolved
src/middleware/jwk/index.test.ts Show resolved Hide resolved
@yusukebe
Copy link
Member

yusukebe commented Feb 2, 2025

@Beyondo

Thank you for your hard work! Almost done. I've left comments.

Copy link
Member

@yusukebe yusukebe left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM!

@yusukebe
Copy link
Member

yusukebe commented Feb 4, 2025

Hi @Beyondo

Looks good. Thank you for your hard work! I'll merge it into the next branch for the next minor version and release it. Maybe soon!

@yusukebe yusukebe changed the base branch from main to next February 6, 2025 13:14
@yusukebe yusukebe merged commit 6837649 into honojs:next Feb 6, 2025
16 checks passed
alexandresoro pushed a commit to alexandresoro/ouca-backend that referenced this pull request Feb 8, 2025
This PR contains the following updates:

| Package | Type | Update | Change |
|---|---|---|---|
| [hono](https://hono.dev) ([source](https://github.com/honojs/hono)) | dependencies | minor | [`4.6.20` -> `4.7.0`](https://renovatebot.com/diffs/npm/hono/4.6.20/4.7.0) |

---

### Release Notes

<details>
<summary>honojs/hono (hono)</summary>

### [`v4.7.0`](https://github.com/honojs/hono/releases/tag/v4.7.0)

[Compare Source](honojs/hono@v4.6.20...v4.7.0)

### Release Notes

Hono v4.7.0 is now available!

This release introduces one helper and two middleware.

-   Proxy Helper
-   Language Middleware
-   JWK Auth Middleware

Plus, Standard Schema Validator has been born.

Let's look at each of these.

#### Proxy Helper

We sometimes use the Hono application as a reverse proxy. In that case, it accesses the backend using `fetch`. However, it sends an unintended headers.

```ts
app.all('/proxy/:path', (c) => {
  // Send unintended header values to the origin server
  return fetch(`http://${originServer}/${c.req.param('path')}`)
})
```

For example, `fetch` may send `Accept-Encoding`, causing the origin server to return a compressed response. Some runtimes automatically decode it, leading to a `Content-Length` mismatch and potential client-side errors.

Also, you should probably remove some of the headers sent from the origin server, such as `Transfer-Encoding`.

[Proxy Helper](https://hono.dev/docs/helpers/proxy) will send requests to the origin and handle responses properly. The above headers problem is solved simply by writing as follows.

```ts
import { Hono } from 'hono'
import { proxy } from 'hono/proxy'

app.get('/proxy/:path', (c) => {
  return proxy(`http://${originServer}/${c.req.param('path')}`)
})
```

You can also use it in more complex ways.

```ts
app.get('/proxy/:path', async (c) => {
  const res = await proxy(
    `http://${originServer}/${c.req.param('path')}`,
    {
      headers: {
        ...c.req.header(),
        'X-Forwarded-For': '127.0.0.1',
        'X-Forwarded-Host': c.req.header('host'),
        Authorization: undefined,
      },
    }
  )
  res.headers.delete('Set-Cookie')
  return res
})
```

Thanks [@&#8203;usualoma](https://github.com/usualoma)!

#### Language Middleware

[Language Middleware](https://hono.dev/docs/middleware/builtin/language) provides 18n functions to Hono applications. By using the `languageDetector` function, you can get the language that your application should support.

```ts
import { Hono } from 'hono'
import { languageDetector } from 'hono/language'

const app = new Hono()

app.use(
  languageDetector({
    supportedLanguages: ['en', 'ar', 'ja'], // Must include fallback
    fallbackLanguage: 'en', // Required
  })
)

app.get('/', (c) => {
  const lang = c.get('language')
  return c.text(`Hello! Your language is ${lang}`)
})
```

You can get the target language in various ways, not just by using `Accept-Language`.

-   Query parameters
-   Cookies
-   `Accept-Language` header
-   URL path

Thanks [@&#8203;lord007tn](https://github.com/lord007tn)!

#### JWK Auth Middleware

Finally, middleware that supports JWK (JSON Web Key) has landed. Using [JWK Auth Middleware](https://hono.dev/docs/middleware/builtin/jwk), you can authenticate by verifying JWK tokens. It can access keys fetched from the specified URL.

```ts
import { Hono } from 'hono'
import { jwk } from 'hono/jwk'

app.use(
  '/auth/*',
  jwk({
    jwks_uri: `https://${backendServer}/.well-known/jwks.json`,
  })
)

app.get('/auth/page', (c) => {
  return c.text('You are authorized')
})
```

Thanks [@&#8203;Beyondo](https://github.com/Beyondo)!

#### Standard Schema Validator

[Standard Schema](https://standardschema.dev/) provides a common interface for TypeScript validator libraries. [Standard Schema Validator](https://github.com/honojs/middleware/tree/main/packages/standard-validator) is a validator that uses it. This means that Standard Schema Validator can handle several validators, such as Zod, Valibot, and ArkType, with the same interface.

The code below really works!

```ts
import { Hono } from 'hono'
import { sValidator } from '@&#8203;hono/standard-validator'
import { type } from 'arktype'
import * as v from 'valibot'
import { z } from 'zod'

const aSchema = type({
  agent: 'string',
})

const vSchema = v.object({
  slag: v.string(),
})

const zSchema = z.object({
  name: z.string(),
})

const app = new Hono()

app.get(
  '/:slag',
  sValidator('header', aSchema),
  sValidator('param', vSchema),
  sValidator('query', zSchema),
  (c) => {
    const headerValue = c.req.valid('header')
    const paramValue = c.req.valid('param')
    const queryValue = c.req.valid('query')
    return c.json({ headerValue, paramValue, queryValue })
  }
)

const res = await app.request('/foo?name=foo', {
  headers: {
    agent: 'foo',
  },
})

console.log(await res.json())
```

Thanks [@&#8203;muningis](https://github.com/muningis)!

#### New features

-   feat(helper/proxy): introduce proxy helper honojs/hono#3589
-   feat(logger): include query params honojs/hono#3702
-   feat: add language detector middleware and helpers honojs/hono#3787
-   feat(hono/context): add buffer returns honojs/hono#3813
-   feat(hono/jwk): JWK Auth Middleware honojs/hono#3826
-   feat(etag): allow for custom hashing methods to be used to etag honojs/hono#3832
-   feat(router): support greedy matches with subsequent static components honojs/hono#3888

#### All changes

-   docs(CONTRIBUTING): remove text about `yarn` by [@&#8203;EdamAme-x](https://github.com/EdamAme-x) in honojs/hono#3878
-   refactor(request): `toLowerCase()` is unnecessary for `req.header()` by [@&#8203;yusukebe](https://github.com/yusukebe) in honojs/hono#3880
-   fix(helper/adapter): correct `env` type by [@&#8203;yusukebe](https://github.com/yusukebe) in honojs/hono#3885
-   chore(test): update to vitest 3 by [@&#8203;yasuaki640](https://github.com/yasuaki640) in honojs/hono#3861
-   fix(router/trie-router): fix label with trailing wildcard pattern by [@&#8203;usualoma](https://github.com/usualoma) in honojs/hono#3892
-   feat(helper/proxy): introduce proxy helper by [@&#8203;usualoma](https://github.com/usualoma) in honojs/hono#3589
-   feat(logger): include query params by [@&#8203;ryuapp](https://github.com/ryuapp) in honojs/hono#3702
-   feat(factory): Allow HonoOptions<E> with factory by [@&#8203;miyaji255](https://github.com/miyaji255) in honojs/hono#3786
-   feat: add language detector middleware and helpers by [@&#8203;lord007tn](https://github.com/lord007tn) in honojs/hono#3787
-   feat(hono/context): add buffer returns by [@&#8203;askorupskyy](https://github.com/askorupskyy) in honojs/hono#3813
-   feat(hono/jwk): JWK Auth Middleware by [@&#8203;Beyondo](https://github.com/Beyondo) in honojs/hono#3826
-   feat(etag): allow for custom hashing methods to be used to etag by [@&#8203;EdamAme-x](https://github.com/EdamAme-x) in honojs/hono#3832
-   feat(router): support greedy matches with subsequent static components. by [@&#8203;usualoma](https://github.com/usualoma) in honojs/hono#3888
-   fix(client): correct inferring empty object from`c.json({})` by [@&#8203;yusukebe](https://github.com/yusukebe) in honojs/hono#3873
-   Next by [@&#8203;yusukebe](https://github.com/yusukebe) in honojs/hono#3896
-   chore(runtime-tests): add `deno.lock` by [@&#8203;yusukebe](https://github.com/yusukebe) in honojs/hono#3897

#### New Contributors

-   [@&#8203;lord007tn](https://github.com/lord007tn) made their first contribution in honojs/hono#3787

**Full Changelog**: honojs/hono@v4.6.20...v4.7.0

</details>

---

### Configuration

📅 **Schedule**: Branch creation - At any time (no schedule defined), Automerge - At any time (no schedule defined).

🚦 **Automerge**: Disabled by config. Please merge this manually once you are satisfied.

♻ **Rebasing**: Whenever PR becomes conflicted, or you tick the rebase/retry checkbox.

🔕 **Ignore**: Close this PR and you won't be reminded about this update again.

---

 - [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check this box

---

This PR has been generated by [Renovate Bot](https://github.com/renovatebot/renovate).
<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiIzOS4xNjIuMiIsInVwZGF0ZWRJblZlciI6IjM5LjE2Mi4yIiwidGFyZ2V0QnJhbmNoIjoibWFpbiIsImxhYmVscyI6WyJkZXBlbmRlbmNpZXMiXX0=-->

Reviewed-on: https://git.tristess.app/alexandresoro/ouca-backend/pulls/537
Reviewed-by: Alexandre Soro <[email protected]>
Co-authored-by: renovate <[email protected]>
Co-committed-by: renovate <[email protected]>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants