lexik / LexikJWTAuthenticationBundle

JWT authentication for your Symfony API
MIT License
2.52k stars 610 forks source link

Advice for verification of Firebase idToken #429

Open lashae opened 6 years ago

lashae commented 6 years ago

We are using Firebase integration in our project. We have chosen the path of customToken implementation. Firebase customToken implementation works as follows:

The problem is that; Firebase uses multiple private keys to generate the idToken, therefore the token we receive from Firebase is decodable with one of the Firebase public keys.

The header part of the idToken is as follows:

{
  "alg": "RS256",
  "kid": "19f07ad8152b2fc4e427cb25e9306edaca41a635"
}

The key kid corresponds to the id of the public key, namely we need to match this kid with the Firebase public keys and verify the token with the corresponding key.

Although we know that LexikJwtAuhenticationBundle is not designed for this scenario, since it is highly customizable, we would like to use it because we are very comfortable and pleased to use it.

There can be multiple approaches to solving the "multiple public key supported verification" procedure, what is your advice? Which services would you override if you were us? :-)

Potential services to override:

Spomky commented 6 years ago

@chalasr: I think this issue could be linked to https://github.com/lexik/LexikJWTAuthenticationBundle/issues/409#issuecomment-343415965. I tried to figure out how to solve that using my bridge. The public key set can be converted into a JWKSet, but the token cannot be loaded as the issuer is not the expected one (I am not sure it is a good idea to ignore such claim).

lashae commented 6 years ago

I'm on the way to implement the requirements. What I have implemented so far is:

A custom JWSProvider (FirebaseJWSProvider) and a custom KeyLoader (FirebaseOpenSSLKeyLoader).

Implemented a compilerPass as follows:

class OverrideServiceCompilerPass implements CompilerPassInterface
{
    /**
     * {@inheritdoc}
     */
    public function process(ContainerBuilder $container)
    {
        $definition = $container->getDefinition('lexik_jwt_authentication.jws_provider.default');
        $definition->setClass(FirebaseJWSProvider::class);
        $definition->replaceArgument(0, new Reference(FirebaseOpenSSLKeyLoader::class));
    }
}

I would very much appreciate to support my use case within the bundle and open to contribute if it makes sense. @chalasr @Spomky

Spomky commented 6 years ago

With the help of @chalasr, I developed a bridge that may help you and I think it can help you to solve that issue.

Before the installation of the bridge, you have to retrieve your public/private keys and convert them into the JWKSet format There is a dedicated console command that will do the job for you:

# Download the application and its public key (this is a signed app)
curl -OL https://github.com/web-token/jwt-app/raw/gh-pages/jose.phar
curl -OL https://github.com/web-token/jwt-app/raw/gh-pages/jose.phar.pubkey

# Load the public keys and convert into a public key set
./jose.phar keyset:load:x5u https://www.googleapis.com/robot/v1/metadata/x509/securetoken@system.gserviceaccount.com > public-keys.jwkset

# Convert your private key into a private key set
# The private key should be int the private_key.pem file.
./jose.phar keyset:add:key '{"keys":{}}' $(./jose.phar key:load:key private_key.pem) > private-keys.jwkset

# Merge the two key sets into one
./jose.phar keyset:merge $(cat private-keys.jwkset) $(cat public-keys.jwkset) > all_keys.jwkset

# Delete the unused files
rm public-keys.jwkset
rm private-keys.jwkset
rm jose.phar
rm jose.phar.pubkey

Now the file all_keys.jwkset contains your private key (at index 0) followed by the public keys from Firebase.

The next step is the installation and the configuration of bridge. The configuration file should be as follow:

lexik_jose:
    ttl: 3600
    server_name: 'https://gservice....../' // Should be the same as the "iss" claim set in the Firebase tokens
    key_set: '%env(file:ALL_KEYS)%' // Use this with SF 3.4+. ALL_KEYS is the env var that points to all_keys.jwkset. With SF3.3 and earlier, copy-paste the content of the file "all_keys.jwkset"
    key_index: 0 // Index of the private key used to signed the tokens issued by the bundle
    signature_algorithm: "RSxxx" // The signature algorithm used by Firebase (RS256 I guess).

Normally the bridge is automatically set as the default encoder. If not, just set the following configuration:

lexik_jwt_authentication:
    encoder:
        service:  'SpomkyLabs\LexikJoseBundle\Encoder\LexikJoseEncoder'
lashae commented 6 years ago

Thank you very much for your time and verbose message. However, there are some issues.

The public keys are subject to change/rotation and the whole idea of publishing them on a URL is the probability of "rotation". Google, periodically rotates its keys to conform their security standards. Therefore, the implementation should detect that a JWT with an "unknown keyId" has arrived and refresh the public key repository. I suppose this is not possible with your bridge.

Second and I think the most important issue is (maybe pinging @chalasr at this point can be beneficial) there are read-only file systems and Symfony4 is going to support them officially. For example, Google App Engine is one of them. If your application require s you to save the keys on the server disk and keys changes, application cannot update the keys reactively. The only thing you can do is, "be informed" of the key rotation "somehow" (t1). Create a new version of your application and deploy the new version to your server or Google App Engine (t2). And in between t1 to t2 users cannot authenticate.

What I offer for the second issiue is: Instead of obligating the key loading from the disk (KeyLoader's file_get_contents directives) some sort of key retrieval abstraction can be implemented. For example, I have chosen to store the keys on my Redis server.

If KeyLoaders are feeded with an extra argument of type KeyRepositoryInterface and call getter method of this argument instead of file_get_contents, this will enable new opportunities.

interface KeyRepositoryInterface {
   public function getKey($type, $keyId='default');
   public function setKey($type, $key, $keyId='default');
   public function addKey($type, $key, $keyId='default');
   public function removeKey($type, $keyId);
}

Officially, this bundle can distribute just the FilesystemKeyRepository however we can implement the following easily:

etc.

What do you think of this proposal? I suppose, a lot of people can benefit from this change in the future?

Spomky commented 6 years ago

Hi, thank you for your feedback.

In the example I wrote, the script creates a file with the keys, but nothing prevent you from setting directly an env var. This is what I do every day for one of my applications. The script I use retrieves the keys from the Google server and update an env var using the heroku CLI. That script is called from another server every hours by cron.

My script:

curl -OL https://github.com/web-token/jwt-app/raw/gh-pages/jose.phar
curl -OL https://github.com/web-token/jwt-app/raw/gh-pages/jose.phar.pubkey

heroku config:set GOOGLE_PUBLIC_KEYS=$(./jose.phar keyset:load:jku https://www.googleapis.com/oauth2/v3/certs)

And the configuration in my application directly uses %env(GOOGLE_PUBLIC_KEYS)%.

In any case, I agree with you that changes have to be done with this bundle to completely decouple the token life cycle (issuance/loading) from the firewall part.

chalasr commented 6 years ago

I would be more than happy to see a PR.

hpatoio commented 6 years ago

I've created this demo project that allow to verify a user logged in on firebase https://github.com/hpatoio/api-platform-jwt-firebase

sallaben commented 3 years ago

I've created this demo project that allow to verify a user logged in on firebase https://github.com/hpatoio/api-platform-jwt-firebase

link broken

hpatoio commented 3 years ago

I deleted my repo. Was old and I thought no one was using it.