Giter Club home page Giter Club logo

lexik-jose-bridge's Introduction

Spomky-Labs

lexik-jose-bridge's People

Contributors

chalasr avatar dependabot[bot] avatar drupol avatar garthbrantley avatar hpatoio avatar jaspernbrouwer avatar mtrojanowski avatar smatyas avatar spomky avatar temp avatar tereschenkov avatar wietsedecnijf avatar

Stargazers

 avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar

Watchers

 avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar

lexik-jose-bridge's Issues

Missing dependency for jose.key_set.lexik_jose_bridge.signature

Hello,

When trying to require this bundle in my app, I end up with:

Symfony operations: 17 recipes (b2d1c116c5cf892274952afbca6be32a)
  - Configuring symfony/framework-bundle (>=5.2): From github.com/symfony/recipes:master
  - Configuring symfony/security-bundle (>=5.1): From github.com/symfony/recipes:master
  - Configuring symfony/routing (>=5.1): From github.com/symfony/recipes:master
  - Configuring doctrine/annotations (>=1.0): From github.com/symfony/recipes:master
  - Configuring sensio/framework-extra-bundle (>=5.2): From github.com/symfony/recipes:master
  - Configuring lexik/jwt-authentication-bundle (>=2.5): From github.com/symfony/recipes:master
  - Configuring ecphp/api-gw-authenticator (>=dev-master): From auto-generated recipe
  - Configuring nyholm/psr7 (>=1.0): From github.com/symfony/recipes:master
  - Configuring web-token/jwt-bundle (>=v2.2.8): From auto-generated recipe
  -  WARNING  spomky-labs/lexik-jose-bridge (>=2.0): From github.com/symfony/recipes-contrib:master
    The recipe for this package comes from the "contrib" repository, which is open to community contributions.
    Review the recipe at https://github.com/symfony/recipes-contrib/tree/master/spomky-labs/lexik-jose-bridge/2.0

    Do you want to execute this recipe?
    [y] Yes
    [n] No
    [a] Yes for all packages, only for the current installation session
    [p] Yes permanently, never ask again for this project
    (defaults to n): yes
  - Configuring spomky-labs/lexik-jose-bridge (>=2.0): From github.com/symfony/recipes-contrib:master
  - Configuring symfony/console (>=5.1): From github.com/symfony/recipes:master
  - Configuring symfony/debug-bundle (>=4.1): From github.com/symfony/recipes:master
  - Configuring symfony/monolog-bundle (>=3.3): From github.com/symfony/recipes:master
  - Configuring symfony/validator (>=4.3): From github.com/symfony/recipes:master
  - Configuring symfony/twig-bundle (>=5.0): From github.com/symfony/recipes:master
  - Configuring symfony/web-profiler-bundle (>=3.3): From github.com/symfony/recipes:master
  - Configuring twig/extensions (>=1.0): From github.com/symfony/recipes:master
Executing script cache:clear [KO]
 [KO]
Script cache:clear returned with error code 1
!!
!!  In CheckExceptionOnInvalidReferenceBehaviorPass.php line 86:
!!
!!    The service "jose.key_set.lexik_jose_bridge.signature" has a dependency on
!!    a non-existent service "Jose\Component\KeyManagement\JKUFactory". Did you m
!!    ean this: "Jose\Component\KeyManagement\JWKFactory"?
!!
!!
!!
Script @auto-scripts was called via post-update-cmd
$

JWT Token is always invalid

Hello,
I use this Bundle to check a JWS sent by my own KeyCloak.
The app workflow can be described like this:

  • the VueJS webapp get the token from KeyCloak using the oidc-client-js package (Certified package by OpendID Connect)
  • the VueJS webapp send a GET request to my Symfony 4.4 using the token and the Bearer authentication
  • Symfony responds with 401 - Invalid JWT Token

Using a debugger, I found that the exception is thrown by Lexik-Jose-bridge when checking the Token signature (verifySignature in JWSVerifier returns false because of $algorithm->verify())

For exemple, the config looks like this:

#.env File

###> spomky-labs/lexik-jose-bridge ###
SL_JOSE_BRIDGE_SERVER_NAME=http://keycloak.biometrie.test/auth/realms/Biometrie
SL_JOSE_BRIDGE_SIGNATURE_KEYSET='{"keys":[{"kid":"R0ziM07whcBe1-UcHvimwf1WZQLei3WszfaErj50kVc","kty":"RSA","alg":"RS256","use":"sig","n":"lhqyXCOxPLGHO4TgiJ0SByoCRBUUSFnn6EiBFOpbQPNtuDpAri_IjP3s_S3lL77pHjorTa4EYXNBK-b0bXsNSx6vOzZF04lDc0n-O8O47kBeB1GUm_-pGcn_kWZKHxOKnkhjBlyT2EP2l_Ps_Nzqn4cjocPDqUu61DLpu5AOh-R6kHKGKzkvxAXoi3bQEfpijP0QvHtMH51CTvVmVHPyK8w_fGggH8pXefrw2SOroTd7UbatHNFPpjvER_AmRJQQdF15mL-U4slPo6AxahTiLE6aARpPVuopFVuSgGvImNtzEIxhZAV4agAqKMuNPG_-1LwUVx8Vcg5pCIIY64G1Fw","e":"AQAB","x5c":["MIICmzCCAYMCBgFv5kao7TANBgkqhkiG9w0BAQsFADARMQ8wDQYDVQQDDAZtYXN0ZXIwHhcNMjAwMTI3MDkxMTI4WhcNMzAwMTI3MDkxMzA4WjARMQ8wDQYDVQQDDAZtYXN0ZXIwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQCWGrJcI7E8sYc7hOCInRIHKgJEFRRIWefoSIEU6ltA8224OkCuL8iM/ez9LeUvvukeOitNrgRhc0Er5vRtew1LHq87NkXTiUNzSf47w7juQF4HUZSb/6kZyf+RZkofE4qeSGMGXJPYQ/aX8+z83OqfhyOhw8OpS7rUMum7kA6H5HqQcoYrOS/EBeiLdtAR+mKM/RC8e0wfnUJO9WZUc/IrzD98aCAfyld5+vDZI6uhN3tRtq0c0U+mO8RH8CZElBB0XXmYv5TiyU+joDFqFOIsTpoBGk9W6ikVW5KAa8iY23MQjGFkBXhqACooy408b/7UvBRXHxVyDmkIghjrgbUXAgMBAAEwDQYJKoZIhvcNAQELBQADggEBAGoGSh7NLsDUJI/VLAh/GKJL8KdNE/vRT5ix4eVMBj30A62G9e6IxcqNEf+gxsy/WpavPm8dj5KE9LWWJhXq4bqEr2y3E27feYgCjDFZJV5Qbka8I/c1pHxh/y0MI5aa807F6lneYQWottx4vkthqaqM8YA3lWyqINYwy3oZ5pZRCu8U+a0Ijb/A/wFumcw+DWA4zR0Ukn0AGGwLo+UllekHEYPN8YaOtpPTT9db0ecTKEGvJXQCO8LAuvGyOqtdJGBU2EUShfeJYlcJWY2iuuEki6NM54UWRpCfRD8lbfY4qJAMI5pS450Tt4dr5ueavA1zZQ/gDA8v78G/yj0KbP8="],"x5t":"WACX0jnZYYIHH2GbPX1GMdzOy4o","x5t#S256":"B6bMYytBGjflssF_cL0zMIUYIx699Lq72Q8qj8s6sxo"}]}'
###< spomky-labs/lexik-jose-bridge ###
#spomky_labs_lexik_jose_bridge_bundle.yaml
lexik_jose:
    ttl: 3600
    server_name: '%env(SL_JOSE_BRIDGE_SERVER_NAME)%'
    audience: 'account'
    key_set: '%env(SL_JOSE_BRIDGE_SIGNATURE_KEYSET)%'
    key_index: 0
    signature_algorithm: "RS256"
    mandatory_claims:
        - 'aud'

Example of a token to be verified by the bundle:
eyJhbGciOiJSUzI1NiIsInR5cCIgOiAiSldUIiwia2lkIiA6ICJfMlE5aW9EM1RtWXBHR19FNVBRQ3JfU0VYNEoxVEFTWmZKczZrZTJ5eFpFIn0.eyJqdGkiOiJmZWVkMDg0Ni0yNDc5LTQ4NWQtYmY1ZC0yYzFhZjQ0ZmJiNzYiLCJleHAiOjE1ODAxMTY4ODksIm5iZiI6MCwiaWF0IjoxNTgwMTE2NTg5LCJpc3MiOiJodHRwOi8va2V5Y2xvYWsuYmlvbWV0cmllLnRlc3QvYXV0aC9yZWFsbXMvQmlvbWV0cmllIiwiYXVkIjoiYWNjb3VudCIsInN1YiI6ImE5NTVmNDNiLTBhOTEtNDU0NS1hOWY2LWU5NjYxZGYzNTkwYyIsInR5cCI6IkJlYXJlciIsImF6cCI6ImJpb21ldHJpZV9jbGllbnQiLCJhdXRoX3RpbWUiOjE1ODAxMTY1ODgsInNlc3Npb25fc3RhdGUiOiIxN2RjYWM3OS1iMjk0LTQ4ZjgtOGY3NS1lZjM4NTM4OThjMzEiLCJhY3IiOiIxIiwiYWxsb3dlZC1vcmlnaW5zIjpbImh0dHA6Ly9iaW9tZXRyaWUudGVzdCJdLCJyZWFsbV9hY2Nlc3MiOnsicm9sZXMiOlsib2ZmbGluZV9hY2Nlc3MiLCJ1bWFfYXV0aG9yaXphdGlvbiJdfSwicmVzb3VyY2VfYWNjZXNzIjp7ImJpb21ldHJpZV9jbGllbnQiOnsicm9sZXMiOlsiUk9MRV9BRE1JTiJdfSwiYWNjb3VudCI6eyJyb2xlcyI6WyJtYW5hZ2UtYWNjb3VudCIsIm1hbmFnZS1hY2NvdW50LWxpbmtzIiwidmlldy1wcm9maWxlIl19fSwic2NvcGUiOiJvcGVuaWQgcHJvZmlsZSBlbWFpbCIsImVtYWlsX3ZlcmlmaWVkIjp0cnVlLCJuYW1lIjoiVmljdG9yIENhc3Ryby1DSW50YXMiLCJwcmVmZXJyZWRfdXNlcm5hbWUiOiJ2Y2FzdHJvIiwiZ2l2ZW5fbmFtZSI6IlZpY3RvciIsImZhbWlseV9uYW1lIjoiQ2FzdHJvLUNJbnRhcyIsImVtYWlsIjoidmljdG9yLmNhc3Ryby5jaW50YXNAZ21haWwuY29tIn0.eKjzIPQyxphLuNI5ooEf_u5ReQFAb4t372tBKUwFofYUXxB8JAZ2fyxI3OKGi2jxO8zIjdJM7t8Viin6i9Q1uOWMAgLRUW1SzpcWeQ_9oZVLwjawtquVL2LqxWDQHO1tj9tm4sBjK0SEqaA1l-Q0Zmtt-YKHB_1i7d_u-K2RrciNXMxnTpTqPd5OMN0_xNRV3BKdQqfs2veKFsCRNdq6mXeKrxk6W7GUsI5He6MdJ1R6eGnMlGFhLkiePaUSSYr0K1xehuFr5BawA-1BNeCfPhKwsn95rhWGD5b9WmPNmoV9K6gzmJ4MplzYWL2u0PudPF0SJVDMaMSkxUf4pe0SDA

Multi-value for audience param

Hello,

We are currently developing an Angular frontend to call our API Symfony where OIDC is used to call REST Controller (with JWT included in the headers).

As the frontend calls several APIs which are secured with the same strategy as done with Symfony, the user (Angular) send a JWT Token with a audience value set by our IDP, let's say "myAngular".

Now the issue with lexik-jose-bridge, if the audience is not set in the configuration, the server_name value is used by default and fall in 401 ERROR because the value "myAngular" <> "serveur_name_value" !

I think using a single value in the configuration (audience) leads to a big issue in case where this API could be called by many clients (SPA, Java, etc...) as the clients cannot have the same audience value.

How to manage to force Symfony API accepting many values under "audience" param ? is any workaround ?

I noticed that the class AudienceChecker from the bundle "web-token/jwt-checker": "^3.2" accepts an array to declare many values (ie. the checkValue method) , so why in the bridge you've change this behavior ?

<?php

declare(strict_types=1);

namespace Jose\Component\Checker;

use function in_array;
use function is_array;
use function is_string;

/**
 * This class is a header parameter and claim checker. When the "aud" header parameter or claim is present, it will
 * check if the value is within the allowed ones.
 */
final class AudienceChecker implements ClaimChecker, HeaderChecker
{
    private const CLAIM_NAME = 'aud';

    public function __construct(
        private readonly string $audience,
        private readonly bool $protectedHeader = false
    ) {
    }

    /**
     * {@inheritdoc}
     */
    public function checkClaim(mixed $value): void
    {
        $this->checkValue($value, InvalidClaimException::class);
    }

    /**
     * {@inheritdoc}
     */
    public function checkHeader(mixed $value): void
    {
        $this->checkValue($value, InvalidHeaderException::class);
    }

    public function supportedClaim(): string
    {
        return self::CLAIM_NAME;
    }

    public function supportedHeader(): string
    {
        return self::CLAIM_NAME;
    }

    public function protectedHeaderOnly(): bool
    {
        return $this->protectedHeader;
    }

    private function checkValue(mixed $value, string $class): void
    {
        if (is_string($value) && $value !== $this->audience) {
            throw new $class('Bad audience.', self::CLAIM_NAME, $value);
        }
        if (is_array($value) && ! in_array($this->audience, $value, true)) {
            throw new $class('Bad audience.', self::CLAIM_NAME, $value);
        }
        if (! is_array($value) && ! is_string($value)) {
            throw new $class('Bad audience.', self::CLAIM_NAME, $value);
        }
    }
}

Thanks

Mehdi

mutli-value audience parameter ?

Hi,

As reported in the issue => Multi-value for audience param #79 , it would be great to support an array of audiences instead of one value, currently the v4.0 supports only one value and it's inconvenient in multi-clients situation (ie. micro-services) as every client has its own audience value.

We appreciate this bundle, it's straightforward and easy to use, keep going guys.

Mehdi

Token Revocation support

To allow the user to logout and to be sure that the token is not reused by a malicious application, the bundle should be able to verify that the token is not revoked.

no encoder

Everything is set-up according to docs - but there's no encoder.

bin/console dump:container|grep encoder

  [Symfony\Component\DependencyInjection\Exception\ServiceNotFoundException]
  You have requested a non-existent service "lexik_jose_bridge.encoder".

Using Symfony 3.2.7

Latency on PHP Docker image + JWT Symfony validation issue

Hello,

I'm facing a latency problem that I didn't figure it out yet, in fact on Bitnami/Symfony:6.3 Docker image it works fine, but if I switch to php:8.2-apache or php:8.2-fpm the REST Controller takes 20 secondes to decode and check signed JWT !

If I disable the bridge and use only Lexik bundle with the public key (JWKS) embedded within the Symfony app there is no latency.

I've already reported the problem (look link below) on Lexik github project but I was doing some tests today and I've discovered something strange with lexik-jose-bridge, if the JWKS is hard coded in the yaml, whatever the value of the key (indexed 0) the bridge validates the JWT, even if change some chars in the key deliberately !

I tested V3.0.2 and 4.0.0 both validate the JWT with a false JWKS of our ADFS.

lexik/LexikJWTAuthenticationBundle#1162

thanks in advance.

Is it possible to call a service for key_set?

Hello,
Is it possible to define a service which returns the key_set instead of of the %env(LEXIK_JOSE_SIGNATURE_KEYSET)%?

lexik_jose:
    key_set: '%env(LEXIK_JOSE_SIGNATURE_KEYSET)%'

Thank

When keys are changed by new ones, valid tokens are rejected

When the signature or the encryption key is updated, valid tokens are rejected because they cannot be decrypted or the signature cannot be verified.

The bundle should keep in a keyset the old keys for few minutes (at least the TTL).

How to reproduce:

  1. Use the bundle and issue a token
  2. Change a key (signature en encryption)
  3. The newly issued token is rejected

Use RotatableJWKSet and add a console command for key rotation

The Jose library now offers an easy way to create a JWKSet and optionally rotate keys after a period of time.
This feature could be used so that the bundle will automatically create a JWKSet for signature operations and, if enabled, encryption depending on algorithms to use.
This feature will remove up to 15 configuration lines:

Because the developers should not care about cryptographic keys and algorithms, then

  • the signature algorithm should be set by default.
  • the encryption of the token could be always enabled (TBD).
  • the encryption algorithms should be set by default.

The bundle could also provide a console command to rotate keys if they are older than a period of time passed as an argument (e.g. bin/console lexik_jose:rotate-keys "7 days").

This will also fix #13.

  • RotatableJKWSet integration
  • Encryption for all tokens (security feature)
  • Console command for key rotation (security feature)
  • Console command for key regen (when an algorithm is changed)
  • Default signature algorithm in the configuration
  • Default encryption algorithms in the configuration

Symfony flex endpoint order

Version(s) affected: v4.0.0

Description

With the provided installation instructions of adding your flex recipes endpoint, Symfony Flex does not honor extra.symfony.require anymore.

How to reproduce

  1. Install a simple Symfony skeleton with a fixed Symfony version (here current LTS):
    mkdir skeleton && cd skeleton
    composer create-project symfony/skeleton:"5.4.*" .
    symfony/* packages are kept to 5.4.*
  2. Add your custom recipes endpoint as per README.md
    composer config --json extra.symfony.endpoint '["https://api.github.com/repos/Spomky-Labs/recipes/contents/index.json?ref=main", "flex://defaults"]'
  3. Run composer update -> symfony/* packages are updated to latest 6.2.* versions

Possible Solution

Swap the order of extra.symfony.endpoint so that flex://defaults is first:

  1. Run
    composer config --json extra.symfony.endpoint '["flex://defaults", "https://api.github.com/repos/Spomky-Labs/recipes/contents/index.json?ref=main"]'
  2. Run composer update -> symfony/* packages are downgraded to latest 5.4.* versions as expected

This has the disadvantage that the recipe for spomky-labs/lexik-jose-bridge will be taken from the recipes contrib repo instead of your custom one. See output after composer req spomky-labs/lexik-jose-bridge:

Symfony operations: 4 recipes (84563b0c21a9ca4cdcdfc632a8ffa7a7)
  - Configuring web-token/jwt-bundle (>=3.0): From github.com/Spomky-Labs/recipes:tree
  - Configuring symfony/security-bundle (>=5.3): From github.com/symfony/recipes:main
  - Configuring lexik/jwt-authentication-bundle (>=2.5): From github.com/symfony/recipes:main
  -  WARNING  spomky-labs/lexik-jose-bridge (>=2.0): From github.com/symfony/recipes-contrib:main
    The recipe for this package comes from the "contrib" repository, which is open to community contributions.
    Review the recipe at https://github.com/symfony/recipes-contrib/tree/main/spomky-labs/lexik-jose-bridge/2.0

    Do you want to execute this recipe?
    [y] Yes
    [n] No
    [a] Yes for all packages, only for the current installation session
    [p] Yes permanently, never ask again for this project
    (defaults to n):

Additional context

This is probably a bug in Symfony Flex, as their instructions propose the order as used by you. I could file the bug with them if you would confirm this is not caused by your custom flex recipe endpoint somehow.

Invalid definition for service Jose\Bundle\JoseFramework\DataCollector\CheckerCollector: argument 2 of Jose\Bundle\JoseFramework\DataCollect or\CheckerCollector::addHeaderCheckerManager()

i get the following error message, when running "php bin/console lint:container" in the symfony project:

Invalid definition for service "Jose\Bundle\JoseFramework\DataCollector\CheckerCollector": argument 2 of "Jose\Bundle\JoseFramework\DataCollector\CheckerCollector::addHeaderCheckerManager()" accepts "Jose\Bundle\JoseFramework\Services\HeaderCheckerManager", "Jose\Component\Signature\JWSVerifier" passed.
the linter is part of our deployment process and i need to solve the issue to use the bundle

To Reproduce
i guess install the package and run the linter "php bin/console lint:container"

symfony 4.4.*, php 7.4.1

issuer same as audience

These 2 services :

    spomkylabs_lexik_jose_checker_audience:
        class: 'Jose\Component\Checker\AudienceChecker'
        arguments:
            - '%lexik_jose_bridge.encoder.issuer%'
            - true
        tags:
            - { name: 'jose.checker.claim', alias: 'lexik_jose_audience' }
            - { name: 'jose.checker.header', alias: 'lexik_jose_audience' }

and

    spomkylabs_lexik_jose_checker_issuer:
        class: 'SpomkyLabs\LexikJoseBundle\Checker\IssuerChecker'
        arguments:
            - '%lexik_jose_bridge.encoder.issuer%'
        tags:
            - { name: 'jose.checker.claim', alias: 'lexik_jose_issuer' }
            - { name: 'jose.checker.header', alias: 'lexik_jose_issuer' 

They both receive the same parameter lexik_jose_bridge.encoder.issuer. This make impossible to have different values for audience and issuer.

AFAIK these 2 value can be different. Take for instance JWT token generated by Firebase. According to the doc here https://firebase.google.com/docs/auth/admin/verify-id-tokens the valie for iss is
https://securetoken.google.com/<audience>

I've already fixed this on my local branch but before opening a PR I just wanna know if you agree that we should have different parameters for audience and issuer

Flex recipe - generated key_index

While following the installation instructions I encountered a problem with configuration for key_index, for both signature and encryption.

The key_index and kid in data generated by Flex recipe do not match in .env and in the yaml config.

Which resulted in

An error occurred while trying to encode the JWT token: Undefined index.
In LexikJoseEncoder.php (line 193)

After changing key_index to match kid everything works.

TTL Token must be int but string given

Version(s) affected: v3.0.3

Description
in my .env i got:
###> lexik/jwt-authentication-bundle ### JWT_PASSPHRASE=xxxxxxxxxxxxxxxxxxxxx JWT_REFRESH_TTL=2592000 # one month JWT_TTL=86400 # 1 day

in my "spomky_labs_lexik_jose_bridge_bundle.yaml" i got:

ttl: '%env(int:JWT_TTL)%'

when i start a composer install i get:

Invalid type for path "lexik_jose.ttl". Expected "int", but got "string".

How to reproduce
install the package and try to set the TTL in spomky_labs_lexik_jose_bridge_bundle.yaml via a .env var

Bundle upgrade

A global upgrade of this bundle should be done to include Symfony 5 in tests and drop old PHP version if necessary.

Do not override lexik_jwt_authentication.encoder.service automatically

The lexik-jose-bridge always overrides lexik_jwt_authentication.encoder.service, which isn't necessary.
Since it can be simply set to the required value LexikJoseEncoder::class, this should not be forced.

But since this would break existing installations, maybe this behaviour could be deprecated, and turned off by a switch?
Maybe something like lexik_jose.disable_automatic_encoder_override: false?

Background: In prod we have a key set from azure, and with our local tests we want locally created private/public keys. But this isn't possible with the enforced encoder.

Recommend Projects

  • React photo React

    A declarative, efficient, and flexible JavaScript library for building user interfaces.

  • Vue.js photo Vue.js

    ๐Ÿ–– Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.

  • Typescript photo Typescript

    TypeScript is a superset of JavaScript that compiles to clean JavaScript output.

  • TensorFlow photo TensorFlow

    An Open Source Machine Learning Framework for Everyone

  • Django photo Django

    The Web framework for perfectionists with deadlines.

  • D3 photo D3

    Bring data to life with SVG, Canvas and HTML. ๐Ÿ“Š๐Ÿ“ˆ๐ŸŽ‰

Recommend Topics

  • javascript

    JavaScript (JS) is a lightweight interpreted programming language with first-class functions.

  • web

    Some thing interesting about web. New door for the world.

  • server

    A server is a program made to process requests and deliver data to clients.

  • Machine learning

    Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.

  • Game

    Some thing interesting about game, make everyone happy.

Recommend Org

  • Facebook photo Facebook

    We are working to build community through open source technology. NB: members must have two-factor auth.

  • Microsoft photo Microsoft

    Open source projects and samples from Microsoft.

  • Google photo Google

    Google โค๏ธ Open Source for everyone.

  • D3 photo D3

    Data-Driven Documents codes.