diff --git a/classes/Locator.php b/classes/Locator.php index 4b34c0c888642f12204180881d88799b3da10fac..e1709a200004921c397d95e9eb0f1448bdfb79d1 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 2b45d367145fccd8a32d64a99aa4f495748738a4..b3f585ee7b0507ca321554324fd001089dadc1f1 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 22d74f9c7fb28713e9a02a0aedc6765e9f1bc623..64ae0f1c6578240a539c78acaab7eb0ab37dd8c1 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 370bc8526f6470d145f93e8798ce79d7aed4a74c..946a49664ad38636d148c8d65e94fd074a907454 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 a397478b8146cf3db6bc4b644618968084779fd1..f37841093eaca595dc0706e36f144bbfbfe05d2c 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 56b2021328db081c0060904d57a757fa96852529..14a205bcc24d5fdd6e166e09c45f03679afa0b6d 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 c4e78543b83082f7a3758a46622cec2d89c3dd68..ef1972940d1edcc94c2cc6b1c813ee43929d043a 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)); });