Validating RS256-signed JSON Web Tokens in Erlang

2015-04-19 14:29:00 +0000

I’m currently playing with OpenID Connect (OAuth 2.0 for Login), to allow people to log into a web site using their Google account. The web site is built using Erlang.

By following the Google documentation, I’ve managed to send an authentication request to Google, exchange the code returned for an ID token, and extract the user information from the ID token.

The documentation says:

since you are communicating directly with Google over an intermediary-free HTTPS channel and using your client secret to authenticate yourself to Google, you can be confident that the token you receive really comes from Google and is valid.

However, I wanted to deal with the token validation as well. Sample code for C#, Java, Ruby etc. exists, but I couldn’t find anything for Erlang, so…

ID Token

If you use Google OpenID Connect, you get back an id_token that looks something like eyJhMifQ.eyJpMzR9.rG-sbtCc.

This is a JSON Web Token (JWT).

It’s in three parts (header, payload and signature), separated by dots. Each part is base64url-encoded. Erlang doesn’t have a built-in base64url module, so you’ll have to find one.

[H, P, S] = binary:split(Token, <<".">>, [global]).
Header = jiffy:decode(base64url:decode(H), [return_maps]).
Payload = jiffy:decode(base64url:decode(P), [return_maps]).
Signature = base64url:decode(S).

Key ID

The header is JSON, and looks something like this:

{
  "alg": "RS256",
  "kid": "8faca3e0eff37d416d0a8a9770d8f09c7eeffce3"
}

The signing algorithm is given is given in the header as RS256, which is “RSA using SHA-256 hash algorithm”. The particular certificate to be used is specified by the kid field.

#{<<"kid">> := KId} = Header.

Google’s OpenID Configuration

To find Google’s JWT signing certificates, we first need to get the Discovery document for Google’s OpenID Connect service, which is at https://accounts.google.com/.well-known/openid-configuration:

{ok, _} = application:ensure_all_started(inets).
{ok, _} = application:ensure_all_started(ssl).

ConfigurationUrl =
    "https://accounts.google.com/.well-known/openid-configuration".
{ok, { {_, 200, _}, _, ConfigurationJson}} =
    httpc:request(ConfigurationUrl).
Configuration = jiffy:decode(ConfigurationJson, [return_maps]).

Google Signing Keys

The discovery document contains a field jwks_uri which points to the JWT signing keys:

#{<<"jwks_uri">> := JwksUri} = Configuration.

And we can go and get that document:

{ok, { {_, 200, _}, _, JwksJson}} =
    httpc:request(binary_to_list(JwksUri)).

This returns a JSON object that looks like the following:

{
  "keys": [
    {
      "kty": "RSA", "alg": "RS256", "use": "sig",
      "kid": "8faca3e0eff37d416d0a8a9770d8f09c7eeffce3",
      "n": "xJiA...5Kik", "e": "AQAB"
    },

    ...
  ]
}

The real document has more than one key in it. You should look up the entry where the kid field matches the one specified in the token.

According to the Google documentation, we should cache this document. The Cache-Control and Expires HTTP headers appear to be set appropriately for this.

Jwks = jiffy:decode(JwksJson, [return_maps]).
#{<<"keys">> := Keys} = Jwks.
[Key] = lists:filter(
            fun(Key) ->
                #{<<"kid">> := K} = Key,
                K =:= KId
            end, Keys).

Public Key Modulus and Exponent

We need the public key modulus and exponent, which are n and e respectively. They’re base64url-encoded, and we want them as integers, so:

#{<<"n">> := N0, <<"e">> := E0} = Key.
N1 = base64url:decode(N0).
E1 = base64url:decode(E0).
N = binary:decode_unsigned(N1).
E = binary:decode_unsigned(E1).

Validating the signature

Msg = iolist_to_binary([H, <<".">>, P]),
IsValid = crypto:verify(rsa, sha256, Msg, Signature, [E, N]).

Done?

Not quite. The documentation requires 5 steps:

  1. Verify that the ID token is a JWT which is properly signed with an appropriate Google public key.
  2. Verify that the value of aud in the ID token is equal to your app’s client ID.
  3. Verify that the value of iss in the ID token is equal to accounts.google.com or https://accounts.google.com.
  4. Verify that the expiry time (exp) of the ID token has not passed.
  5. If you passed a hd parameter in the request, verify that the ID token has a hd claim that matches your Google Apps hosted domain.

We’ve only done the first step, but it is the hardest. The others are left as an exercise for the reader.