How to verify firebase ID token with PHP(JWT)?

Eren picture Eren · Feb 7, 2017 · Viewed 13.7k times · Source

I have a shared hosting plan which has only PHP(no Java, no node.js). I need to send firebase ID token from my android app and verify it by PHP-JWT.

I am following the tutorial: Verify Firebase ID tokens

It says:

"If your backend is in a language that doesn't have an official Firebase Admin SDK, you can still verify ID tokens. First, find a third-party JWT library for your language. Then, verify the header, payload, and signature of the ID token."

I found that library: Firebase-PHP-JWT. In gitHub example; i couldn't understand the

$key part:

`$key = "example_key";` 

and

$token part:

`$token = array(
    "iss" => "http://example.org",
    "aud" => "http://example.com",
    "iat" => 1356999524,
    "nbf" => 1357000000
);`

My questions:

  1. What should be the $key variable?
  2. Why the &token variable is an array? Token which will be sent from mobile app is a String.
  3. If somebody could post a full example of verifying firebase ID with PHP-JWT, i would appreciate it.

EDIT:

Okey i got the point. GitHub example shows how to generate JWT code(encode) and how to decode it. In my case i need only decode the jwt which encoded by firebase. So, i need to use only this code:

$decoded = JWT::decode($jwt, $key, array('HS256'));

In this code part $jwt is the firebase ID token. For $key variable documentation says:

Finally, ensure that the ID token was signed by the private key corresponding to the token's kid claim. Grab the public key from https://www.googleapis.com/robot/v1/metadata/x509/[email protected] and use a JWT library to verify the signature. Use the value of max-age in the Cache-Control header of the response from that endpoint to know when to refresh the public keys.

I didn't understand how to pass this public keys to decode function. Keys are something like this:

"-----BEGIN CERTIFICATE-----\nMIIDHDCCAgSgAwIBAgIIZ36AHgMyvnQwDQYJKoZIhvcNAQEFBQAwMTEvMC0GA1UE\nAxMmc2VjdXJldG9rZW4uc3lzdGVtLmdzZXJ2aWNlYWNjb3VudC5jb20wHhcNMTcw\nMjA4MDA0NTI2WhcNMTcwMjExMDExNTI2WjAxMS8wLQYDVQQDEyZzZWN1cmV0b2tl\nbi5zeXN0ZW0uZ3NlcnZpY2VhY2NvdW50LmNvbTCCASIwDQYJKoZIhvcNAQEBBQAD\nggEPADCCAQoCggEBANBNTpiQplOYizNeLbs+r941T392wiuMWr1gSJEVykFyj7fe\nCCIhS/zrmG9jxVMK905KwceO/FNB4SK+l8GYLb559xZeJ6MFJ7QmRfL7Fjkq7GHS\n0/sOFpjX7vfKjxH5oT65Fb1+Hb4RzdoAjx0zRHkDIHIMiRzV0nYleplqLJXOAc6E\n5HQros8iLdf+ASdqaN0hS0nU5aa/cPu/EHQwfbEgYraZLyn5NtH8SPKIwZIeM7Fr\nnh+SS7JSadsqifrUBRtb//fueZ/FYlWqHEppsuIkbtaQmTjRycg35qpVSEACHkKc\nW05rRsSvz7q1Hucw6Kx/dNBBbkyHrR4Mc/wg31kCAwEAAaM4MDYwDAYDVR0TAQH/\nBAIwADAOBgNVHQ8BAf8EBAMCB4AwFgYDVR0lAQH/BAwwCgYIKwYBBQUHAwIwDQYJ\nKoZIhvcNAQEFBQADggEBAEuYEtvmZ4uReMQhE3P0iI4wkB36kWBe1mZZAwLA5A+U\niEODMVKaaCGqZXrJTRhvEa20KRFrfuGQO7U3FgOMyWmX3drl40cNZNb3Ry8rsuVi\nR1dxy6HpC39zba/DsgL07enZPMDksLRNv0dVZ/X/wMrTLrwwrglpCBYUlxGT9RrU\nf8nAwLr1E4EpXxOVDXAX8bNBl3TCb2fu6DT62ZSmlJV40K+wTRUlCqIewzJ0wMt6\nO8+6kVdgZH4iKLi8gVjdcFfNsEpbOBoZqjipJ63l4A3mfxOkma0d2XgKR12KAfYX\ncAVPgihAPoNoUPJK0Nj+CmvNlUBXCrl9TtqGjK7AKi8=\n-----END CERTIFICATE-----\n"

Do i need to convert this public key to something before pass it? I tried to remove all "\n" and "-----BEGIN CERTIFICATE-----", "-----BEGIN CERTIFICATE-----"...But no luck. Still i get invalid signature error. Any advice?

Answer

Gergely Kőrössy picture Gergely Kőrössy · Feb 8, 2017

HS256 is used only if you use a password to sign the token. Firebase uses RS256 when it issues a token, thus, you need the public keys from the given URL, and you need to set the algorithm to RS256.

Also note that the token you get in your application should not be an array but a string that has 3 parts: header, body, and signature. Each part is separated by a ., thus it gives you a simple string: header.body.signature

What you need to do in order to verify the tokens is downloading the public keys from the given URL regularly (check the Cache-Control header for that info) and saving it (the JSON) in a file, so you won't have to retrieve it every time you need to check the JWT. Then you can read in the file and decode the JSON. The decoded object can be passed to the JWT::decode(...) function. Here's a short sample:

$pkeys_raw = file_get_contents("cached_public_keys.json");
$pkeys = json_decode($pkeys_raw, true);

$decoded = JWT::decode($token, $pkeys, ["RS256"]);

Now the $decoded variable contains the payload of the token. Once you have the decoded object, you still need to verify it. According to the guide on ID token verification, you have to check the following things:

  • exp is in the future
  • iat is in the past
  • iss: https://securetoken.google.com/<firebaseProjectID>
  • aud: <firebaseProjectID>
  • sub is non-empty

So, for example, you can check iss like this (where FIREBASE_APP_ID is the app ID from the firebase console):

$iss_is_valid = isset($decoded->iss) && $decoded->iss === "https://securetoken.google.com/" . FIREBASE_APP_ID;

Here is a complete sample for refreshing the keys and retrieving them.

Disclaimer: I haven't tested it and this is basically for informational purposes only.

$keys_file = "securetoken.json"; // the file for the downloaded public keys
$cache_file = "pkeys.cache"; // this file contains the next time the system has to revalidate the keys

/**
 * Checks whether new keys should be downloaded, and retrieves them, if needed.
 */
function checkKeys()
{
    if (file_exists($cache_file)) {
        $fp = fopen($cache_file, "r+");

        if (flock($fp, LOCK_SH)) {
            $contents = fread($fp, filesize($cache_file));
            if ($contents > time()) {
                flock($fp, LOCK_UN);
            } elseif (flock($fp, LOCK_EX)) { // upgrading the lock to exclusive (write)
                // here we need to revalidate since another process could've got to the LOCK_EX part before this
                if (fread($fp, filesize($this->cache_file)) <= time()) {
                    $this->refreshKeys($fp);
                }
                flock($fp, LOCK_UN);
            } else {
                throw new \RuntimeException('Cannot refresh keys: file lock upgrade error.');
            }
        } else {
            // you need to handle this by signaling error
            throw new \RuntimeException('Cannot refresh keys: file lock error.');
        }

        fclose($fp);
    } else {
        refreshKeys();
    }
}

/**
 * Downloads the public keys and writes them in a file. This also sets the new cache revalidation time.
 * @param null $fp the file pointer of the cache time file
 */
function refreshKeys($fp = null)
{
    $ch = curl_init();
    curl_setopt($ch, CURLOPT_URL, "https://www.googleapis.com/robot/v1/metadata/x509/[email protected]");
    curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1);
    curl_setopt($ch, CURLOPT_HEADER, 1);

    $data = curl_exec($ch);

    $header_size = curl_getinfo($ch, CURLINFO_HEADER_SIZE);
    $headers = trim(substr($data, 0, $header_size));
    $raw_keys = trim(substr($data, $header_size));

    if (preg_match('/age:[ ]+?(\d+)/i', $headers, $age_matches) === 1) {
        $age = $age_matches[1];

        if (preg_match('/cache-control:.+?max-age=(\d+)/i', $headers, $max_age_matches) === 1) {
            $valid_for = $max_age_matches[1] - $age;
            ftruncate($fp, 0);
            fwrite($fp, "" . (time() + $valid_for));
            fflush($fp);
            // $fp will be closed outside, we don't have to

            $fp_keys = fopen($keys_file, "w");
            if (flock($fp_keys, LOCK_EX)) {
                fwrite($fp_keys, $raw_keys);
                fflush($fp_keys);
                flock($fp_keys, LOCK_UN);
            }
            fclose($fp_keys);
        }
    }
}

/**
 * Retrieves the downloaded keys.
 * This should be called anytime you need the keys (i.e. for decoding / verification).
 * @return null|string
 */
function getKeys()
{
    $fp = fopen($keys_file, "r");
    $keys = null;

    if (flock($fp, LOCK_SH)) {
        $keys = fread($fp, filesize($keys_file));
        flock($fp, LOCK_UN);
    }

    fclose($fp);

    return $keys;
}

The best thing would be scheduling a cronjob to call checkKeys() whenever needed, but I don't know if your provider allows that. Instead of that, you can do this for every request:

checkKeys();
$pkeys_raw = getKeys(); // check if $raw_keys is not null before using it!