Validating RS256-signed JSON Web Tokens in Erlang
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 eyJh
…MifQ.eyJp
…MzR9.rG-s
…btCc
.
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:
Verify that the ID token is a JWT which is properly signed with an appropriate Google public key.- Verify that the value of
aud
in the ID token is equal to your app’s client ID. - Verify that the value of
iss
in the ID token is equal toaccounts.google.com
orhttps://accounts.google.com
. - Verify that the expiry time (
exp
) of the ID token has not passed. - If you passed a
hd
parameter in the request, verify that the ID token has ahd
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.