Using Joken to validate Google JWTs

I’ve been trying out the Joken package to work with JWTs. Right now I’m unable to verify the JWTs that Google generates during Oauth. Has anyone implemented this? I think Joken has all the pieces, I just can’t figure out the winning combination. I’ve been reading this: https://developers.google.com/identity/sign-in/web/backend-auth

Google’s public keys are available in JWK format https://www.googleapis.com/oauth2/v3/certs
and in PEM format: https://www.googleapis.com/oauth2/v1/certs

I can get Google JWT, and I’ve tried to set up a module for these particular Google JWTs:

defmodule GoogleJwt do
  
  use Joken.Config, default_signer: nil # no signer

  def token_config do
    default_claims()
  end
end

But the following always generates an error of invalid signature:

MyGoogleJwt.verify_and_validate(idtoken)

Anyone have any tips?

1 Like

I think your first mistake is using the mechanism where you use Joken.Config to create a custom module for handling tokens. In this case you want to handle someone else’s tokens so I’m not sure what you’re doing is the right strategy. But more on that later.

You’re going to have to do something to get a Joken Signer based on the Google keys.

In my application I have the signing keys specified as JWK maps. I create the Joken Signer using:

signer = Joken.Signer.create(signing_key["alg"], signing_key)

I then verify the token as:

Joken.verify(iam_token, signer, [])

This lets me know that the claims in the token were signed by the signer which is all I want to know.

If you want to validate that the verified claims are good, then you woud create a token_config() with the validations added. Riffing on an example in the docs it might look something like:

token_config =         %{} # empty claim map
        |> add_claim("name", nil, &(&1 == "John Doe")) # name has to be John Doe
        |> add_claim("test", nil, &(&1 == true)) # test has to be true
        |> add_claim("age", nil, &(&1 > 18))  # age must be > 18

Then you could pass the token_config to Joken.verify_and_validate

Joken.verify_and_validate(token_config, your_token, google_signer)

If you were going to make the mechanism you have above work (with the use Joken.Config), you would have to provide a default_signer. Joken expects you to specify the default_signer in your application’s config.exs. It wants you to provide the signer that your app would use to sign its own tokens. You might be able to do that grab Google’s the signing keys then, query the web when config.exs is compiled, but if the keys changed you’d be in trouble.

(you would also implement a token_config function in your module to return something like the token_config constructed in the sample above)

You might be able to create a function that grabs the signature at runtime and jams the default signer into the application config using Application.put_env - something along the lines of Application.put_env(:your_app_key, GoogleJWT, default_signer: signer). You’d need a strategy to update that signer (either periodically, or when a verification fails… something like that).

3 Likes

YMMV, but I found it much easier to validate tokens using the JOSE library directly than with Jokens. The errors contain more information on what failed, and the code is much easier to follow (no macros, overrides, callbacks).

3 Likes

Thanks easco… yeah, I have no desire to tie into the whole config stuff just to verify that Google’s token is valid, but I can’t seem to make much work in the Joken package – I’ve tried variants of your solution, but no dice. I’m not sure where Joken.verify(iam_token, signer, []) comes from, but it generates an error for me:
function Joken.Signer.verify/3 is undefined or private.

I am assuming that Google’s certs listed at https://www.googleapis.com/oauth2/v1/certs are PEMs, but I’m pretty much just guessing at the exact algorithm. The docs don’t appear to list the supported algorithms, but one of the error messages states that the possible values are
[“HS256”, “HS384”, “HS512”, “RS256”, “RS384”, “RS512”, “ES256”, “ES384”, “ES512”, “PS256”, “PS384”, “PS512”, “Ed25519”, “Ed25519ph”, “Ed448”, “Ed448ph”]

However, trying to use some of these comes up with errors that the algorithm isn’t recognized. So that leaves me pretty confused. The best I’ve been able to do is to get a {:error, :signature_error}

Converting the Google RSA cert into an Elixir data structure looks like this:

keys = %{
    keys: [
      %{
        alg: "RS256",
        n: "mSLCSG1hK28xrzcSfgbvRinkIRjecBlwsQggynHppHiiT6I80waivIqTJBSFYyVuRCAHXi6apSsL5FUWKd42GOhVUayIyzvuz1CqTuh5a9ACXaJjEVLUFO39QfXxWrxhpSJCTN9aMkdtoV1QJqfAd3IF9MYwfojsoEn3d5XX5TX4RxqZ9-HGbgSLsRuAzFIg9NxxfTYhbECBskhhR4RIcam-1T52FafmK2LMiuIEDPiVg6LvAqWi8gdMRd8WhiP_ZIRJTCH4C0NFKmw1PZyKadVxvwg97vwPTF8qkFdwJ_kjQAMmq77PxankluAkfWjFqbD4JepO4HH3aJvU8Sl_Ow",
        use: "sig",
        kid: "08d3245c62f86b6362afcbbffe1d069826dd1dc1",
        e: "AQAB",
        kty: "RSA"
      },
      %{
        alg: "RS256",
        n: "uS9Iep_r83oLpfnMXLnB5a8IVUP7ZRreM1rxNWYnaqEQr1NfRisyIi4cYG7KbWiuLCmRQOD7ybhpdHCcN9ty5evz4irWT5hIa98Jr3a2BISTskBbPmBgUR3_TuQ_fvxeQYCCETJUcho5gXK-yeDWJwcD2iwqpVzIZHz8BBe5AYFUlJMzwgzYMe9aqoOEWVv__Gd7Z_kaz5pa0lOsWUUPNFmeW4e4rtNvosx7ItyyyghIyG2KX-0phOgbfzG6Ub6qA9upBYK9KBtjcoe1ciV-Yn_3HaS5PlugYTo1zYnng1mW7UP5A_QT_HgDqD1clcz0WIEL6usVMRay87ECEmOhrw",
        use: "sig",
        kid: "b15a2b8f7a6b3f6bc08bc1c56a88410e146d01fd",
        e: "AQAB",
        kty: "RSA"
      }
    ]
  }
  
Joken.Signer.verify(idtoken, Joken.Signer.create("RS256", keys))

But this too throws an error:

Joken.Signer.verify(idtoken, Joken.Signer.create("RS256", keys))
** (FunctionClauseError) no function clause matching in JOSE.JWK.from_record/1

Thanks dom, I looked at Jose, but unfortunately its documentation is so sparse that I think only an expert could make use of it. I think I have a decent handle on SSH and certs, and I couldn’t follow it.

It’s really not too bad. This is what I use to validate a JWT token against AWS Cognito keyset:

keys
|> JOSE.JWK.from()
|> Enum.map(&JOSE.JWK.to_record/1)

keys
|> Stream.map(fn key ->
  case :jose_jwt.verify(key, token) do
    {true, {:jose_jwt, data}, _} ->
      data

    _ ->
      nil
  end
end)
|> Enum.find(& &1)
|> case do
  nil ->
    :error

  %{"username" => username} ->
    {:ok, username}
end

I retrieve the keyset on application boot, shove them into an ets table, and then use the Enum.find bit with a given token to see if the token is valid with any of the keys. I looked at Joken and it doesn’t appear to support this use case.

3 Likes

You’ve got two keys in the keys array:

keys = %{
    keys: [
      %{

You will either need to verify the JWT against both keys, or find additional information about which key you should use. In my case, the key is identified using a “kid” key in the header of the JWT. I use Joken.peek_header(my_token) to peek at the header and find out which key was used to sign the token, then I pull that key out of the list of keys and verify the token against it.

1 Like

We were joking in the office about handing out a button that says “IT WORKS FOR ME” any time code failed for one dev but worked for another. If you have gotten the JOSE to work, then I suppose it’s “not too bad”, but for me it was a genuine struggle. Eventually I got it to work. Thank you for sharing your example.

Ultimately I could never work out how to use the RSA certs at https://www.googleapis.com/oauth2/v3/certs I would only get errors like this:

** (FunctionClauseError) no function clause matching in JOSE.JWK.from_record/1
The following arguments were given to JOSE.JWK.from_record/1:

    # 1
    {:error, {:missing_required_keys, ["keys", "kty"]}}

I could never figure out what exactly needed to be done to any single key or a list of keys to make them work with JOSE. As far as I could tell, those are all in standard JWK format…

However, the PEM format is much simpler to work with, and per @easco I used Joken.peek_header(my_token) to get the exact key in PEM format from https://www.googleapis.com/oauth2/v1/certs that was used to sign the JWT.

I can run something like:

token = "from google oauth"
pem = "--- relevant key copied from https://www.googleapis.com/oauth2/v1/certs"
jwk = JOSE.JWK.from_pem(pem)
JOSE.JWT.verify_strict(jwk, ["RS256"], token)

Note: Confusingly, on https://hexdocs.pm/jose/JOSE.JWK.html#content, the args are ordered signed, jwk
But on https://github.com/potatosalad/erlang-jose the args are ordered jwk, compact_signed

The other very important note here is that the JWT that you get once you complete Google’s OAuth is fairly short-lived – maybe only valid for a couple minutes. Also tricky is the fact the public keys used to sign those keys change intermittently, so one must check Google’s cert page pretty often.

Once that is done, however, I was finally able to verify that my Google JWT was valid, and I think I have gained enough clarity here to submit a PR for the relevant JOSE docs/examples.

1 Like

I had the same problem as you. After some struggle I did manage to validate Google idToken with Joken. But as you said the public keys are regularly rotated, which means we need to fetch the key every time when validating? (I checked the official NodeJS library, which does exactly this.)

Meanwhile, Google provides an endpoint to validate the token for you, which the doc says is only for debugging because it involves network request. This is confusing to me since both methods involve network request.

Do you have any ideas?

According to this:

https://developers.google.com/identity/sign-in/web/backend-auth#verify-the-integrity-of-the-id-token

The Cache-Control header returned with the key indicates when you need to revalidate the key. You can create a process that continuously retrieves the key, and waits for the provided invalidation interval before retrieving the next key.

That process can always return the current valid key and the system only makes net requests when necessary.

3 Likes

Yeah, the tokens rotate, so you will have to query that endpoint periodically to fetch the latest keys. I haven’t written code for that yet, but it should be a simple curl operation or something.

Heya guys. So I’m another soul that struggled with something that was supposed to be much easier for a while. And finally landed here.
So after trying all the libraries out there I’m sticking to JOSE. Thx @dom for mentioning it. As @fireproofsocks pointed out previously

I can run something like:

token = "from google oauth"
pem = "--- relevant key copied from https://www.googleapis.com/oauth2/v1/certs"
jwk = JOSE.JWK.from_pem(pem)
JOSE.JWT.verify_strict(jwk, ["RS256"], token)

Will do the job. Thank you all for the inputs

7 Likes

Sorry for such a delayed response. I’ve replied @fireproofsocks on Joken repo about RS keys configuration and released a more detailed configuration of asymmetric keys guide.

To validate tokens from Google, Microsoft and other that have an OpenID Connect certs endpoint (or similar) with a published JWKS, one can use JokenJwks.

You’d do simply:

defmodule MyGoogleToken do
  use Joken.Config

  add_hook(JokenJwks, strategy: MyGoogleStrategy)

  def token_config do
    # your config here with what you want to validate from the token
  end
end

defmodule MyGoogleJwksStrategy do
  use JokenJwks.DefaultStrategyTemplate

  def init_opts(opts), do: [jwks_url: "https://www.googleapis.com/oauth2/v1/certs"]
end

defmodule MyApp do
  use Application

  def start(_type, _args) do
     children = 
      [
        MyGoogleStrategy,
        # others
      ]
      # other start logic
  end
5 Likes

This example would be an awesome addition to the Hex docs.

If you are just interested in using Google’s certs to validate a JWT issued by them, I made the google_certs hex package.

The Google Certs package will download, cache, and auto refresh Google’s certs. Both v1 PEM format and v3 JWK format are supported and are ready to work with Joken (see GoogleCerts.fetch/1). Here is the example from the how to use with joken section in the docs:

defmodule MyApp.Application do
  @moduledoc false

  use Application
  alias GoogleCerts.CertificateCache
  
  def start(_type, _args) do
    children = [
      CertificateCache
    ]

    opts = [strategy: :one_for_one, name: MyApp.Supervisor]
    Supervisor.start_link(children, opts)
  end
end
defmodule MyApp.Crypto.VerifyHook do
  @moduledoc false

  use Joken.Hooks

  @impl true
  def before_verify(_options, {jwt, %Joken.Signer{} = _signer}) do
    with {:ok, %{"kid" => kid}} <- Joken.peek_header(jwt),
         {:ok, algorithm, key} <- GoogleCerts.fetch(kid) do
      {:cont, {jwt, Joken.Signer.create(algorithm, key)}}
    else
      error -> {:halt, {:error, :no_signer}}
    end
  end
end
defmodule MyApp.Crypto.JWTManager do
  @moduledoc false

  use Joken.Config, default_signer: nil

  @iss "https://accounts.google.com"
  
  # your google client id (usually ends in *.apps.googleusercontent.com)
  defp aud, do: Application.get_env(:my_app, :google_client_id) 

  # reference your custom verify hook here
  add_hook(MyApp.Crypto.VerifyHook) 

  @impl Joken.Config
  def token_config do
    default_claims(skip: [:aud, :iss])
    |> add_claim("iss", nil, &(&1 == @iss))
    |> add_claim("aud", nil, &(&1 == aud()))
  end
end
# anywhere in your app you can verify and validate a Google issued JWT
iex> jwt = "eyJhbGciOiJSUzI1..." # Google issued JWT (api call, uberauth, etc)
iex> {:ok, claims} = JWTManager.verify_and_validate(jwt)
4 Likes