honojs / hono

Web framework built on Web Standards
https://hono.dev
MIT License
18.65k stars 523 forks source link

Support for asymmetric JWT validation (e.g. Auth0 or Clerk) #672

Open cd-slash opened 1 year ago

cd-slash commented 1 year ago

The existing JWT middleware validates tokens where the secret is available (i.e. symmetric validation) but services like Auth0, Clerk and others use asymmetric tokens signed by the service using a secret key that is not made available for validating the token. These tokens need to be validated with the public key.

Should validating asymmetric tokens be supported in Hono? It seems like it would be very useful for anyone who wants to build an API accessible by end users.

I put together a quick proof of concept middleware for Clerk tokens, which grabs the public key(s), caches them in a Cloudflare workers KV and uses them to validate the token. The token's claims are made available to the context using the c.set() function. This code could be refined if it's something that should be supported, and note that the code as drafted introduces a dependency on the jose library for key import and validation.

import type { MiddlewareHandler } from "hono"
import { Context } from "hono"
import { KeyLike, importJWK, jwtVerify } from "jose"

const getPublickKeys = async (
  c: Context
): Promise<{ keys: KeyLike[] } | null> => {
  const cache: KVNamespace = c.env.PUBLIC_JWK_CACHE
  const cachedKeys: string | null = await cache.get("public-keys")
  if (cachedKeys != null) {
    return JSON.parse(cachedKeys)
  } else {
    try {
      const res = await fetch(`${c.env.AUTH_DOMAIN}/.well-known/jwks.json`)
      const freshKeys: { keys: KeyLike[] } = await res.json()
      try {
        await cache.put("public-keys", JSON.stringify(freshKeys), {
          expirationTtl: 432000,
        })
      } catch (e) {
        // don't fail as the validation can still occur if keys can't be cached
        console.log("Error caching public keys: ", e)
      }
      return freshKeys
    } catch (e) {
      console.log("Error getting fresh keys: ", e)
      return null
    }
  }
}

export const asymmetricJwt = (): MiddlewareHandler => {
  return async (c, next) => {
    let jwt: string
    let errors: string[] = []
    const authorization = c.req.header("Authorization")
    if (authorization == null) {
      return c.json({ error: "No auth token found" }, 401)
    } else {
      jwt = authorization.replace(/Bearer\s+/i, "")
    }

    // get public keys from cache, or get new keys and cache them (5 day TTL)
    const publicKeys = await getPublickKeys(c)
    if (publicKeys == null) {
      return c.json({ error: "Failed to fetch public keys" }, 500)
    }

    const [token] = await Promise.all(
      publicKeys.keys.map(async (key) => {
        const parsedKey = await importJWK(key)
        try {
          const valid = await jwtVerify(jwt, parsedKey, {
            issuer: c.env.AUTH_DOMAIN,
            audience: c.env.AUTH_AUDIENCE,
          })
          if (valid) {
            return valid
          }
        } catch (e) {
          // there can be multiple keys, so just collect the errors
          errors.push(`JWT validation failed: ${e}`)
        }
      })
    )

    if (!token) {
      return c.json({ errors: errors }, 401)
    } else {
      c.set("token", token)
      await next()
    }
  }
}
yusukebe commented 1 year ago

Hi @cd-slash !

Thank you for your proposal. It looks good. It's OK to go with it, but we have things to consider.

If you want to make this as third-party middleware, you can develop it in the monorepo github.com/honojs/middleware. And we will also distribute it under @honojs namespace on the npm repository.

Hi @metrue : If you have any opinion, please tell us.

cd-slash commented 1 year ago

Thanks for your comments - I believe I can rewrite to use browser APIs like crypto.subtle to avoid the dependence on Jose. I'll work on doing this.

dagnelies commented 1 year ago

I wanted to use jose recently, but it did not work out of the box on cloudflare workers for me. I guess because of some "subtle" crypto module difference. Therefore, I switched it in favor of https://github.com/tsndr/cloudflare-worker-jwt which worked out of the box for me and was lighter.

AlexErrant commented 1 year ago

J/W - did you open an issue with panva? He seems very responsive. If you were testing on miniflare prior to September, you may have run into this.

cd-slash commented 1 year ago

I found some time to work on this and put together a quick proof of concept to demonstrate that this works without external dependencies, as below:

import type { MiddlewareHandler, Context } from "hono"

// modified from swansontec/rfc4648.js
const parseBase64Url = (string: string): Uint8Array => {
  const encodingChars: string =
    "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"
  const encodingBits: number = 6
  // reduce encoding chars to codes using reduce
  const encodingCodes: { [key: string]: number } = {}

  encodingChars.split("").reduce((acc, char, index) => {
    acc[char] = index
    return acc
  }, encodingCodes)

  // Count the padding bytes:
  let end = string.length
  while (string[end - 1] === "=") {
    --end
  }

  const output = new Uint8Array(((end * encodingBits) / 8) | 0)

  // Parse the data:
  let bits = 0 // Number of bits currently in the buffer
  let buffer = 0 // Bits waiting to be written out, MSB first
  let written = 0 // Next byte to write
  for (let i = 0; i < end; ++i) {
    // Read one character from the string:
    const value = encodingCodes[string[i]]
    if (value === undefined) {
      throw new SyntaxError("Invalid character " + string[i])
    }

    // Append the bits to the buffer:
    buffer = (buffer << encodingBits) | value
    bits += encodingBits

    // Write out some bits if the buffer has a byte's worth:
    if (bits >= 8) {
      bits -= 8
      output[written++] = 0xff & (buffer >> bits)
    }
  }

  // Verify that we have received just enough bits:
  if (bits >= encodingBits || 0xff & (buffer << (8 - bits))) {
    throw new SyntaxError("Unexpected end of data")
  }

  return output
}

const parseJwtPayload = <T extends object = { [k: string]: string | number }>(
  token: string
): T | undefined => {
  // convert token from base64url to base64
  token = token.replace(/-/g, "+").replace(/_/g, "/")
  try {
    return JSON.parse(atob(token.split(".")[1]))
  } catch {
    return undefined
  }
}

const verify = async (jwsObject: string, jwKey: JsonWebKey, c: Context) => {
  const jwsSigningInput = jwsObject.split(".").slice(0, 2).join(".")
  const jwsSignature = jwsObject.split(".")[2]
  const key = await crypto.subtle.importKey(
    "jwk",
    jwKey,
    {
      name: "RSASSA-PKCS1-v1_5",
      hash: { name: "SHA-256" },
    },
    false,
    ["verify"]
  )
  return await crypto.subtle.verify(
    { name: "RSASSA-PKCS1-v1_5" },
    key,
    parseBase64Url(jwsSignature),
    new TextEncoder().encode(jwsSigningInput)
  )
}

const getPublicKeys = async (
  c: Context
): Promise<{ keys: JsonWebKey[] } | null> => {
  const cache: KVNamespace = c.env.PUBLIC_JWK_CACHE
  const cachedKeys: string | null = await cache.get("public-keys")
  if (cachedKeys != null) {
    return JSON.parse(cachedKeys)
  } else {
    try {
      const res = await fetch(`${c.env.AUTH_DOMAIN}/.well-known/jwks.json`)
      const freshKeys: { keys: JsonWebKey[] } = await res.json()
      try {
        await cache.put("public-keys", JSON.stringify(freshKeys), {
          expirationTtl: 432000,
        })
      } catch (e) {
        // don't fail as the validation can still occur if keys can't be cached
        console.log("Error caching public keys: ", e)
      }
      return freshKeys
    } catch (e) {
      console.log("Error getting fresh keys: ", e)
      return null
    }
  }
}

export const asymmetricJwt = (): MiddlewareHandler => {
  return async (c, next) => {
    let jwt: string
    const authorization = c.req.header("Authorization")
    if (authorization == null) {
      return c.json({ error: "No auth token found" }, 401)
    } else {
      jwt = authorization.replace(/Bearer\s+/i, "")
    }

    // get public keys from cache, or get new keys and cache them (5 day TTL)
    const publicKeys = await getPublicKeys(c)
    if (publicKeys == null) {
      return c.json({ error: "Failed to fetch public keys" }, 500)
    }

    const validationResponses = await Promise.all(
      publicKeys.keys.map(async (key) => {
        return await verify(jwt, key, c)
      })
    )

    if (!validationResponses.includes(true)) {
      return c.json({ error: "Invalid token" }, 401)
    } else {
      const payload = parseJwtPayload(jwt)
      if (payload == null) {
        return c.json({ error: "Invalid token payload" }, 401)
      }

      // validate claims
      if (payload.exp < Date.now() / 1000) {
        return c.json({ error: "Token expired" }, 401)
      } else {
        console.log(
          `Token expires in ${Math.floor(
            (payload.exp as number) - Date.now() / 1000
          )} seconds`
        )
      }
      if (payload.aud !== c.env.AUTH_AUDIENCE) {
        return c.json({ error: "Invalid audience" }, 401)
      }
      if (payload.iss !== c.env.AUTH_DOMAIN) {
        return c.json({ error: "Invalid issuer" }, 401)
      }

      c.set("claims", payload)
      c.set("user_id", payload.id)
      c.set("org_id", payload.org_id)
      c.set("org_role", payload.org_role)

      await next()
    }
  }
}

I need to do some work to tidy this up (e.g. moving the claims validation to options) but need to do some research on the various auth providers to make it more durable. Thoughts welcome on the general direction here.

dagnelies commented 1 year ago

@AlexErrant Sorry for the late reply. Indeed, it is looks like an issue with Miniflare rather than jose.

uicowboy commented 1 year ago

Hey @cd-slash, thank you for raising this issue. Any updates by change on your draft? I'm evaluating using Clerk in an app and it would've been great to have a built-in or third-party Hono middleware to use for auth with Clerk.

@yusukebe do you have any recommendations on how to use Clerk with Hono from Cloudflare Workers? Thanks!

wataruoguchi commented 1 year ago

Hello, @cd-slash @uicrafts - I created this project to address the issue. The project includes a Hono Middleware. Can you test it when you have a chance? The library should work for any authentication servers. I tested with Auth0 RS256. Cheers!