From 9be006f4e2345e7a91ae191ecfe98fd363993d97 Mon Sep 17 00:00:00 2001
From: Sonia Zorba <sonia.zorba@inaf.it>
Date: Mon, 13 Jul 2020 15:28:20 +0200
Subject: [PATCH] Added token exchange endpoint

---
 classes/JWKSHandler.php                  | 63 +++++++++++++++++
 classes/TokenBuilder.php                 |  6 +-
 classes/TokenExchanger.php               | 86 ++++++++++++++++++++++++
 classes/datalayer/JWKSDAO.php            |  4 ++
 classes/datalayer/mysql/MySQLJWKSDAO.php | 44 ++++++++++++
 classes/model/PublicJWK.php              | 12 ++++
 config-example.json                      |  3 +-
 include/front-controller.php             |  3 +-
 include/rest-web-service.php             | 11 +++
 sql/setup-database.sql                   |  8 +++
 10 files changed, 234 insertions(+), 6 deletions(-)
 create mode 100644 classes/TokenExchanger.php
 create mode 100644 classes/model/PublicJWK.php

diff --git a/classes/JWKSHandler.php b/classes/JWKSHandler.php
index feffd90..966873f 100644
--- a/classes/JWKSHandler.php
+++ b/classes/JWKSHandler.php
@@ -77,4 +77,67 @@ class JWKSHandler {
         return $matches[1];
     }
 
+    public function loadAllJWKS(): array {
+
+        foreach ($this->locator->config->jwksUrls as $url) {
+            $this->loadJWKS($url);
+        }
+
+        $dao = $this->locator->getJWKSDAO();
+        return $dao->getAllPublicJWK();
+    }
+
+    private function loadJWKS($url) {
+
+        $dao = $this->locator->getJWKSDAO();
+
+        $conn = curl_init($url);
+        curl_setopt($conn, CURLOPT_FOLLOWLOCATION, 1);
+        curl_setopt($conn, CURLOPT_RETURNTRANSFER, true);
+
+        $result = curl_exec($conn);
+        $info = curl_getinfo($conn);
+
+        if ($info['http_code'] === 200) {
+            $jwks = json_decode($result, TRUE);
+
+            foreach ($jwks['keys'] as $key) {
+                $key['url'] = $url;
+                $jwk = $this->getPublicJWK($key);
+                $dao->updatePublicJWK($jwk);
+            }
+        } else {
+            $errorMessage = 'Error while retrieving JWKS: ' . curl_error($conn);
+            error_log($result);
+            curl_close($conn);
+            http_response_code(500);
+            die($errorMessage);
+        }
+
+        curl_close($conn);
+    }
+
+    private function getPublicJWK($data): PublicJWK {
+
+        // Convert Base64 uri-safe variant to default (needed for JWKS)
+        $n = strtr($data['n'], '-_', '+/');
+
+        $rsa = new RSA();
+
+        $key = "<RSAKeyPair>"
+                . "<Modulus>" . $n . "</Modulus>"
+                . "<Exponent>" . $data['e'] . "</Exponent>"
+                . "</RSAKeyPair>";
+
+        $rsa->loadKey($key, RSA::PUBLIC_FORMAT_XML);
+
+        $jwk = new PublicJWK();
+        $jwk->kid = $data['kid'];
+        $jwk->key = $rsa;
+        $jwk->url = $data['url'];
+        $jwk->updateTime = time();
+
+        return $jwk;
+    }
+
 }
diff --git a/classes/TokenBuilder.php b/classes/TokenBuilder.php
index f848112..d42103f 100644
--- a/classes/TokenBuilder.php
+++ b/classes/TokenBuilder.php
@@ -101,17 +101,15 @@ class TokenBuilder {
      * @param int $lifespan in hours
      * @param string $audience target service
      */
-    public function generateNewToken(int $lifespan, string $audience) {
+    public function generateNewToken(string $subject, int $lifespan, string $audience) {
         $keyPair = $this->locator->getJWKSDAO()->getNewestKeyPair();
 
-        $user = $this->locator->getSession()->getUser();
-
         $iat = time();
         $exp = $iat + $lifespan * 3600;
 
         $payload = array(
             'iss' => $this->locator->config->jwtIssuer,
-            'sub' => strval($user->id),
+            'sub' => strval($subject),
             'iat' => $iat,
             'exp' => $exp,
             'aud' => $audience
diff --git a/classes/TokenExchanger.php b/classes/TokenExchanger.php
new file mode 100644
index 0000000..a397478
--- /dev/null
+++ b/classes/TokenExchanger.php
@@ -0,0 +1,86 @@
+<?php
+
+/* ----------------------------------------------------------------------------
+ *               INAF - National Institute for Astrophysics
+ *               IRA  - Radioastronomical Institute - Bologna
+ *               OATS - Astronomical Observatory - Trieste
+ * ----------------------------------------------------------------------------
+ *
+ * Copyright (C) 2016 Istituto Nazionale di Astrofisica
+ *
+ * This program is free software; you can redistribute it and/or modify it under
+ * the terms of the GNU General Public License Version 3 as published by the
+ * Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful, but WITHOUT
+ * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
+ * FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
+ * details.
+ *
+ * You should have received a copy of the GNU General Public License along with
+ * this program; if not, write to the Free Software Foundation, Inc., 51
+ * Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ */
+
+namespace RAP;
+
+use \Firebase\JWT\JWT;
+
+class TokenExchanger {
+
+    private $locator;
+
+    public function __construct(Locator $locator) {
+        $this->locator = $locator;
+    }
+
+    public function exchangeToken(string $token) {
+
+        $key = $this->getKeyForToken($token);
+        $decoded = JWT::decode($token, $key->key, ['RS256']);
+
+        $subject = $decoded->sub;
+        $lifespan = ($decoded->exp - time());
+
+        $data = [];
+
+        $data['access_token'] = $this->locator->getTokenBuilder()->generateNewToken($subject, $lifespan / 3600, "gms");
+        $data['issued_token_type'] = "urn:ietf:params:oauth:token-type:access_token";
+        $data['token_type'] = 'Bearer';
+        $data['expires_in'] = $lifespan;
+
+        return $data;
+    }
+
+    private function getKeyForToken(string $token): PublicJWK {
+
+        $keys = $this->locator->getJWKSDAO()->getAllPublicJWK();
+
+        $parts = explode('.', $token);
+        $head = JWT::jsonDecode(JWT::urlsafeB64Decode($parts[0]));
+
+        $kid = $head->kid;
+
+        $key = $this->getKeyByKid($keys, $kid);
+        if ($key === null) {
+            $keys = $this->locator->getJWKSHandler()->loadAllJWKS();
+        }
+        $key = $this->getKeyByKid($keys, $kid);
+
+        if ($key !== null) {
+            return $key;
+        }
+
+        throw new \Exception("Invalid kid");
+    }
+
+    private function getKeyByKid(array $keys, string $kid): ?PublicJWK {
+        foreach ($keys as $key) {
+            if ($key->kid === $kid) {
+                return $key;
+            }
+        }
+        return null;
+    }
+
+}
diff --git a/classes/datalayer/JWKSDAO.php b/classes/datalayer/JWKSDAO.php
index 9cd83f4..a5b52a6 100644
--- a/classes/datalayer/JWKSDAO.php
+++ b/classes/datalayer/JWKSDAO.php
@@ -11,4 +11,8 @@ interface JWKSDAO {
     public function insertRSAKeyPair(RSAKeyPair $keyPair): RSAKeyPair;
 
     public function getNewestKeyPair(): ?RSAKeyPair;
+
+    public function getAllPublicJWK(): array;
+
+    public function updatePublicJWK(PublicJWK $jwk);
 }
diff --git a/classes/datalayer/mysql/MySQLJWKSDAO.php b/classes/datalayer/mysql/MySQLJWKSDAO.php
index 8e7a83a..5611db6 100644
--- a/classes/datalayer/mysql/MySQLJWKSDAO.php
+++ b/classes/datalayer/mysql/MySQLJWKSDAO.php
@@ -85,4 +85,48 @@ class MySQLJWKSDAO extends BaseMySQLDAO implements JWKSDAO {
         return $keyPair;
     }
 
+    public function getAllPublicJWK(): array {
+
+        $dbh = $this->getDBHandler();
+
+        $query = "SELECT `kid`, `key`, `url`, `update_time` FROM public_jwk";
+
+        $stmt = $dbh->prepare($query);
+        $stmt->execute();
+
+        $keys = [];
+
+        foreach ($stmt->fetchAll() as $row) {
+            array_push($keys, $this->getPublicJWKFromResultRow($row));
+        }
+
+        return $keys;
+    }
+
+    private function getPublicJWKFromResultRow($row): PublicJWK {
+
+        $jwk = new PublicJWK ();
+        $jwk->key = $row['key'];
+        $jwk->kid = $row['kid'];
+        $jwk->url = $row['url'];
+        $jwk->updateTime = $row['update_time'];
+        return $jwk;
+    }
+
+    public function updatePublicJWK(PublicJWK $jwk) {
+
+        $dbh = $this->getDBHandler();
+
+        $query = "INSERT INTO public_jwk(kid, `key`, `url`, update_time) VALUES (:kid, :key, :url, :update_time)"
+                . " ON DUPLICATE KEY UPDATE `key`=:key, `url`=:url, update_time=:update_time";
+
+        $stmt = $dbh->prepare($query);
+        $stmt->bindParam(':kid', $jwk->kid);
+        $stmt->bindParam(':key', $jwk->key);
+        $stmt->bindParam(':url', $jwk->url);
+        $stmt->bindParam(':update_time', $jwk->updateTime);
+
+        $stmt->execute();
+    }
+
 }
diff --git a/classes/model/PublicJWK.php b/classes/model/PublicJWK.php
new file mode 100644
index 0000000..c883bf5
--- /dev/null
+++ b/classes/model/PublicJWK.php
@@ -0,0 +1,12 @@
+<?php
+
+namespace RAP;
+
+class PublicJWK {
+
+    public $kid;
+    public $key;
+    public $url;
+    public $updateTime;
+
+}
diff --git a/config-example.json b/config-example.json
index 14f3e73..adc90f4 100644
--- a/config-example.json
+++ b/config-example.json
@@ -56,5 +56,6 @@
                 "scope": "read:fileserver read:rap"
             }],
         "lifespans": [1, 6, 12, 24]
-    }
+    },
+    "jwksUrls": ["http://service/jwks"]
 }
diff --git a/include/front-controller.php b/include/front-controller.php
index 019da56..41c49f4 100644
--- a/include/front-controller.php
+++ b/include/front-controller.php
@@ -370,7 +370,8 @@ Flight::route('POST /token-issuer', function () {
     }
 
     $tokenBuilder = $locator->getTokenBuilder();
-    $token = $tokenBuilder->generateNewToken($postData['lifespan'], $postData['audit']);
+    $userId = $this->locator->getSession()->getUser()->id;
+    $token = $tokenBuilder->generateNewToken($userId, $postData['lifespan'], $postData['audit']);
 
     header('Content-Type: text/plain');
     header("Content-disposition: attachment; filename=\"token.txt\"");
diff --git a/include/rest-web-service.php b/include/rest-web-service.php
index ac213cf..c4e7854 100644
--- a/include/rest-web-service.php
+++ b/include/rest-web-service.php
@@ -89,3 +89,14 @@ Flight::route('POST ' . $WS_PREFIX . '/user', function() {
 
     Flight::json($user);
 });
+
+Flight::route('POST ' . $WS_PREFIX . '/exchange', function() {
+
+    global $locator;
+
+    $subjectToken = Flight::request()->data['subject_token'];
+
+    $exchanger = new \RAP\TokenExchanger($locator);
+
+    Flight::json($exchanger->exchangeToken($subjectToken));
+});
diff --git a/sql/setup-database.sql b/sql/setup-database.sql
index 2d46eaa..5b3842b 100644
--- a/sql/setup-database.sql
+++ b/sql/setup-database.sql
@@ -83,6 +83,14 @@ CREATE TABLE `rsa_keypairs` (
   PRIMARY KEY (`id`)
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8;
 
+CREATE TABLE `public_jwk` (
+  `kid` varchar(255) NOT NULL,
+  `key` text,
+  `url` text,
+  `update_time` BIGINT NOT NULL,
+  PRIMARY KEY (`kid`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8;
+
 CREATE TABLE `rap_permissions` (
   `user_id` bigint NOT NULL,
   `permission` varchar(255) NOT NULL,
-- 
GitLab