27
votes

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?

4
Firebase version info? Note that you don't verify tokens in PHP. You mint them there, send them to the client, and the client does the verification.Kato
@Kato i use the last version. 'com.google.firebase:firebase-auth:10.0.1'. I didn't get your point. After client log in on mobile, firebase auth returns a token. I want to verify this token on the server-side with PHP to be sure that token generated by firebase or not. If the verification is ok, i will authorize client.Eren
@eren130, do you know how often are the public verification keys changed? Should we cache them an hour, a day, a week? Thanks.andreszs
@andreszs "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."Eren

4 Answers

27
votes

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!
7
votes

Working example of accepted answer. differences of note:

  • Tested and Working

  • works in non-class environments

  • More code showing how to use it for Firebase (simple, one-liner to send the code for verification)

  • UnexpectedValueException covers all sorts of errors you might see (such as expired/invalid keys)

  • Well commented and easy-to-follow

  • returns an array of VERIFIED data from the Firebase Token (you can securely use this data for anything you need)

This is basically a broken-out, easy-to-read/understand PHP version of https://firebase.google.com/docs/auth/admin/verify-id-tokens

NOTE: You can use the getKeys(), refreshKeys(), checkKeys() functions to generate keys for use in any secure api situation (mimicking the features of 'verify_firebase_token' function with your own).

USE:

$verified_array = verify_firebase_token(<THE TOKEN FROM FIREBASE>)

THE CODE:

$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
//////////  MUST REPLACE <YOUR FIREBASE PROJECTID> with your own!
$fbProjectId = <YOUR FIREBASE PROJECTID>;

/////// FROM THIS POINT, YOU CAN COPY/PASTE - NO CHANGES REQUIRED
///  (though read through for various comments!)
function verify_firebase_token($token = '')
{
    global $fbProjectId;
    $return = array();
    $userId = $deviceId = "";
    checkKeys();
    $pkeys_raw = getKeys();
    if (!empty($pkeys_raw)) {
        $pkeys = json_decode($pkeys_raw, true);
        try {
            $decoded = \Firebase\JWT\JWT::decode($token, $pkeys, ["RS256"]);
            if (!empty($_GET['debug'])) {
                echo "<hr>BOTTOM LINE - the decoded data<br>";
                print_r($decoded);
                echo "<hr>";
            }
            if (!empty($decoded)) {
                // do all the verifications Firebase says to do as per https://firebase.google.com/docs/auth/admin/verify-id-tokens
                // exp must be in the future
                $exp = $decoded->exp > time();
                // ist must be in the past
                $iat = $decoded->iat < time();
                // aud must be your Firebase project ID
                $aud = $decoded->aud == $fbProjectId;
                // iss must be "https://securetoken.google.com/<projectId>"
                $iss = $decoded->iss == "https://securetoken.google.com/$fbProjectId";
                // sub must be non-empty and is the UID of the user or device
                $sub = $decoded->sub;
                if ($exp && $iat && $aud && $iss && !empty($sub)) {
                    // we have a confirmed Firebase user!
                    // build an array with data we need for further processing
                    $return['UID'] = $sub;
                    $return['email'] = $decoded->email;
                    $return['email_verified'] = $decoded->email_verified;
                    $return['name'] = $decoded->name;
                    $return['picture'] = $decoded->photo;
                } else {
                    if (!empty($_GET['debug'])) {
                        echo "NOT ALL THE THINGS WERE TRUE!<br>";
                        echo "exp is $exp<br>ist is $iat<br>aud is $aud<br>iss is $iss<br>sub is $sub<br>";
                    }
                    /////// DO FURTHER PROCESSING IF YOU NEED TO
                    // (if $sub is false you may want to still return the data or even enter the verified user into the database at this point.)
                }
            }
        } catch (\UnexpectedValueException $unexpectedValueException) {
            $return['error'] = $unexpectedValueException->getMessage();
            if (!empty($_GET['debug'])) {
                echo "<hr>ERROR! " . $unexpectedValueException->getMessage() . "<hr>";
            }
        }
    }
    return $return;
}
/**
* Checks whether new keys should be downloaded, and retrieves them, if needed.
*/
function checkKeys()
{
    global $cache_file;
    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($cache_file)) <= time()) 
                {
                    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)
{
    global $keys_file;
    $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;
            $fp = fopen($keys_file, "w");
            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()
{
   global $keys_file;
    $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;
}
6
votes

Instead of doing it all manually, you can take a look at this library:
Firebase Tokens or even Firebase Admin SDK for PHP. Caching stuff etc. is already implemented, just take a look at the docs.

Basically you would simply do the following using Firebase Tokens Library:

use Firebase\Auth\Token\HttpKeyStore;
use Firebase\Auth\Token\Verifier;
use Symfony\Component\Cache\Simple\FilesystemCache;

$cache = new FilesystemCache();
$keyStore = new HttpKeyStore(null, $cache);
$verifier = new Verifier($projectId, $keyStore);

    try {
        $verifiedIdToken = $verifier->verifyIdToken($idToken);

        // "If all the above verifications are successful, you can use the subject 
        // (sub) of the ID token as the uid of the corresponding user or device. (see https://firebase.google.com/docs/auth/admin/verify-id-tokens#verify_id_tokens_using_a_third-party_jwt_library)
        echo $verifiedIdToken->getClaim('sub'); // "a-uid"
    } catch (\Firebase\Auth\Token\Exception\ExpiredToken $e) {
        echo $e->getMessage();
    } catch (\Firebase\Auth\Token\Exception\IssuedInTheFuture $e) {
        echo $e->getMessage();
    } catch (\Firebase\Auth\Token\Exception\InvalidToken $e) {
        echo $e->getMessage();
    }
1
votes

If anyone is still interested, @CFP Support's answer is quite good for servers using PHP 5.6, but it does have some bugs while trying to cache the expiration time of the current saved public keys. I've taken that code, and made the necessary corrections:

Requirements in composer.json

{
    "require" : {
        "firebase/php-jwt": "5.2.0"
    }
}

Usage

<?
$verified = verify_firebase_token(<THE TOKEN FROM FIREBASE>);
?>

Functions

<?
# the file for the downloaded public keys
$jwt['keys'] = 'jwt.publickeys.json';

# this file contains the next time the system has to revalidate the keys
$jwt['cache'] = 'jwt.publickeys.cache';

# project ID
$jwt['project_id'] = YOUR_FIREBASE_PROJECT_ID;

# verify token
function verify_firebase_token($token) {
    global $jwt;
    $return = array();
    jwt_check_keys();
    $keys_raw = jwt_get_keys();
    if(!empty($keys_raw)) {
        $keys = json_decode($keys_raw, true);
        try {
            $decoded = \Firebase\JWT\JWT::decode($token, $keys, ['RS256']);
            if(!empty($decoded)) {
                # follow best practices verification-wise
                # https://firebase.google.com/docs/auth/admin/verify-id-tokens

                # exp must be in the future
                $exp = $decoded->exp > time();
                # ist must be in the past
                $iat = $decoded->iat < time();
                # aud must be firebase project ID
                $aud = $decoded->aud == $jwt['project_id'];
                # iss must be https://securetoken.google.com/<projectID>
                $iss = $decoded->iss == 'https://securetoken.google.com/'.$jwt['project_id'];
                # sub must be non-empty and is the UID of the user or device
                $sub = $decoded->sub;
                # check all items
                if($exp && $iat && $aud && $iss && !empty($sub)) {
                    # confirmed firebase user
                    $return['user']['uid'] = $sub;
                    // $return['user']['email'] = $decoded->email;
                    // $return['user']['name'] = $decoded->name;
                    // $return['user']['picture'] = $decoded->picture;
                    // $return['all'] = $decoded;
                } else {
                }
            }
        } catch (\UnexpectedValueException $unexpectedValueException) {
            $return['error'] = $unexpectedValueException->getMessage();
            //$unexpectedValueException->getMessage()
        }
    }
    return $return;
}

# checks whether new keys should be downloaded
# retrieves them if needed
function jwt_check_keys() {
    global $jwt;
    if(file_exists($jwt['cache'])) {
        $fp_cache = fopen($jwt['cache'], 'r+');
        if(flock($fp_cache, LOCK_SH)) {
            $cachetime = fread($fp_cache, filesize($jwt['cache']));
            if($cachetime > time()) {
                # still valid - do nothing
                flock($fp_cache, LOCK_UN);
            } elseif(flock($fp_cache, LOCK_EX)) {
                # expired - refresh public keys
                jwt_refresh_keys();
                flock($fp_cache, LOCK_UN);
            } else {
                throw new \RuntimeException('Cannot refresh keys: file lock upgrade error.');
            }
        } else {
            throw new \RuntimeException('Cannot refresh keys: file lock error.');
        }
        fclose($fp_cache);
    } else {
        # refresh public keys
        jwt_refresh_keys();
    }
}

# downloads the public keys and writes them in a file
# sets the new cache revalidation time
function jwt_refresh_keys() {
    global $jwt;
    $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('/max-age=(\d+)/', $headers, $age_matches) === 1) {
        # update new cache expiration timestamp
        $fp_cache = fopen($jwt['cache'], 'w');
        $age = $age_matches[1];
        fwrite($fp_cache, ''.(time() + $age));
        fflush($fp_cache);

        # update public keys
        $fp_keys = fopen($jwt['keys'], '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)
function jwt_get_keys() {
    global $jwt;
    $fp = fopen($jwt['keys'], 'r');
    $keys = null;
    if(flock($fp, LOCK_SH)) {
        $keys = fread($fp, filesize($jwt['keys']));
        flock($fp, LOCK_UN);
    }
    fclose($fp);
    return $keys;
}
?>