From 46229f4e4a7e5c2c988218bb9df6c5c3b89988a6 Mon Sep 17 00:00:00 2001 From: Sonia Zorba <sonia.zorba@inaf.it> Date: Sun, 10 Jan 2021 09:11:45 +0100 Subject: [PATCH] Improvements on token exchange implementation --- classes/Locator.php | 4 +++ classes/OAuth2RequestHandler.php | 2 ++ classes/TokenBuilder.php | 24 +++++++++++++ classes/TokenChecker.php | 12 +++++-- classes/TokenExchanger.php | 62 ++++++++++++++++++++++++++++++-- include/front-controller.php | 7 +++- include/rest-web-service.php | 2 +- 7 files changed, 105 insertions(+), 8 deletions(-) diff --git a/classes/Locator.php b/classes/Locator.php index 4b34c0c..e1709a2 100644 --- a/classes/Locator.php +++ b/classes/Locator.php @@ -94,6 +94,10 @@ class Locator { return new ClientAuthChecker($this); } + public function getTokenExchanger(): TokenExchanger { + return new TokenExchanger($this); + } + /** * Retrieve the SessionData object from the $_SESSION PHP variable. Create a * new one if it is necessary. diff --git a/classes/OAuth2RequestHandler.php b/classes/OAuth2RequestHandler.php index 2b45d36..b3f585e 100644 --- a/classes/OAuth2RequestHandler.php +++ b/classes/OAuth2RequestHandler.php @@ -106,6 +106,8 @@ class OAuth2RequestHandler { return $this->handleClientCredentialsRequest($headers); case "refresh_token": return $this->handleRefreshTokenRequest($params, $headers); + case "urn:ietf:params:oauth:grant-type:token-exchange": + return $this->locator->getTokenExchanger()->exchangeToken($params, $headers); default: throw new \RAP\BadRequestException("Unsupported grant type " . $params['grant_type']); } diff --git a/classes/TokenBuilder.php b/classes/TokenBuilder.php index 22d74f9..64ae0f1 100644 --- a/classes/TokenBuilder.php +++ b/classes/TokenBuilder.php @@ -117,6 +117,30 @@ class TokenBuilder { return $audiences; } + public function generateToken(array $claims) { + + $iat = time(); + + // basic payload + $payload = array( + 'iss' => $this->locator->config->jwtIssuer, + 'iat' => $iat + ); + + // copy claims passed as parameter + foreach ($claims as $key => $value) { + $payload[$key] = $value; + } + + // set expiration claim if it doesn't exist + if (!array_key_exists('exp', $payload)) { + $payload['exp'] = $iat + 3600; + } + + $keyPair = $this->locator->getJWKSDAO()->getNewestKeyPair(); + return JWT::encode($payload, $keyPair->privateKey, $keyPair->alg, $keyPair->keyId); + } + /** * @param int $lifespan in hours * @param string $audience target service diff --git a/classes/TokenChecker.php b/classes/TokenChecker.php index 370bc85..946a496 100644 --- a/classes/TokenChecker.php +++ b/classes/TokenChecker.php @@ -26,10 +26,10 @@ class TokenChecker { throw new BadRequestException("Invalid token type"); } - return $this->attemptJWTTokenValidation($token); + return $this->getValidTokenObject($token); } - private function attemptJWTTokenValidation($jwt): object { + public function getValidTokenObject(string $jwt): object { $jwtParts = explode('.', $jwt); if (count($jwtParts) === 0) { @@ -47,7 +47,13 @@ class TokenChecker { } try { - return JWT::decode($jwt, $keyPair->publicKey, [$keyPair->alg]); + $token = JWT::decode($jwt, $keyPair->publicKey, [$keyPair->alg]); + + if (!isset($token->sub)) { + throw new UnauthorizedException("Invalid token: missing subject claim"); + } + + return $token; } catch (\Firebase\JWT\ExpiredException $ex) { throw new UnauthorizedException("Access token is expired"); } diff --git a/classes/TokenExchanger.php b/classes/TokenExchanger.php index a397478..f378410 100644 --- a/classes/TokenExchanger.php +++ b/classes/TokenExchanger.php @@ -26,6 +26,9 @@ namespace RAP; use \Firebase\JWT\JWT; +/** + * See https://tools.ietf.org/html/rfc8693 + */ class TokenExchanger { private $locator; @@ -34,9 +37,62 @@ class TokenExchanger { $this->locator = $locator; } - public function exchangeToken(string $token) { + public function exchangeToken(array $params, array $headers): array { + + $this->locator->getClientAuthChecker()->validateClientAuth($headers); + + if ($params['subject_token'] === null) { + throw new BadRequestException("subject_token is required"); + } + if ($params['subject_token_type'] === null) { + throw new BadRequestException("subject_token_type is required"); + } + if (strtolower($params['subject_token_type']) !== 'bearer') { + throw new BadRequestException("subject_token_type " . $params['subject_token_type'] . " not supported"); + } + + $subjectToken = $this->locator->getTokenChecker()->getValidTokenObject($params['subject_token']); + + $claims = array( + 'sub' => $subjectToken->sub + ); + + if ($params['resource'] !== null) { + $claims['resource'] = $params['resource']; + } + if ($params['audience'] !== null) { + $claims['aud'] = $this->getAudienceClaim($params['audience']); + } + if ($params['scope'] !== null) { + $claims['scope'] = $params['scope']; + } + + $accessToken = $this->locator->getTokenBuilder()->generateToken($claims); + + $data = []; + + $data['access_token'] = $accessToken; + $data['issued_token_type'] = "urn:ietf:params:oauth:token-type:jwt"; + $data['token_type'] = 'Bearer'; + + return $data; + } + + private function getAudienceClaim($audienceParam) { + $audiences = explode(' ', $audienceParam); + if (count($audiences) === 1) { + // according to RFC 7519 audience can be a single value or an array + return $audiences[0]; + } + return $audiences; + } + + /** + * DEPRECATED (currently used by portals: to be removed) + */ + public function exchangeTokenOld(string $token) { - $key = $this->getKeyForToken($token); + $key = $this->getExternalKeyForToken($token); $decoded = JWT::decode($token, $key->key, ['RS256']); $subject = $decoded->sub; @@ -52,7 +108,7 @@ class TokenExchanger { return $data; } - private function getKeyForToken(string $token): PublicJWK { + private function getExternalKeyForToken(string $token): PublicJWK { $keys = $this->locator->getJWKSDAO()->getAllPublicJWK(); diff --git a/include/front-controller.php b/include/front-controller.php index 56b2021..14a205b 100644 --- a/include/front-controller.php +++ b/include/front-controller.php @@ -99,7 +99,12 @@ Flight::route('POST /auth/oauth2/token', function() { "code" => filter_input(INPUT_POST, "code", FILTER_SANITIZE_STRING), "redirect_uri" => filter_input(INPUT_POST, "redirect_uri", FILTER_SANITIZE_STRING), "refresh_token" => filter_input(INPUT_POST, "refresh_token", FILTER_SANITIZE_STRING), - "scope" => filter_input(INPUT_POST, "scope", FILTER_SANITIZE_STRING) + "scope" => filter_input(INPUT_POST, "scope", FILTER_SANITIZE_STRING), + // For token exchange + "resource" => filter_input(INPUT_POST, "resource", FILTER_SANITIZE_STRING), + "audience" => filter_input(INPUT_POST, "audience", FILTER_SANITIZE_STRING), + "subject_token" => filter_input(INPUT_POST, "subject_token", FILTER_SANITIZE_STRING), + "subject_token_type" => filter_input(INPUT_POST, "subject_token_type", FILTER_SANITIZE_STRING) ]; $headers = apache_request_headers(); diff --git a/include/rest-web-service.php b/include/rest-web-service.php index c4e7854..ef19729 100644 --- a/include/rest-web-service.php +++ b/include/rest-web-service.php @@ -98,5 +98,5 @@ Flight::route('POST ' . $WS_PREFIX . '/exchange', function() { $exchanger = new \RAP\TokenExchanger($locator); - Flight::json($exchanger->exchangeToken($subjectToken)); + Flight::json($exchanger->exchangeTokenOld($subjectToken)); }); -- GitLab