Commit 7c3b5220 authored by Sonia Zorba's avatar Sonia Zorba Committed by zonia3000
Browse files

#5 changed access token from reference token (opaque string) to JWT; Added...

#5 changed access token from reference token (opaque string) to JWT; Added multiple audiences management (JWT claim generation based on scope list); increased security storing hashes instead of plain values for refresh_token and codes; General refactoring (TBC)
parent fde10a6f
Loading
Loading
Loading
Loading
+10 −0
Original line number Diff line number Diff line
@@ -128,3 +128,13 @@ Create the logs directory and assign ownership to the Apache user (usually www-d
## Additional information and developer guide

See the wiki: https://www.ict.inaf.it/gitlab/zorba/rap-ia2/wikis/home

## Troubleshooting

### Class not found while developing

If you see a message like this:

    PHP Fatal error:  Uncaught Error: Class 'RAP\\[...]' not found

probably you have to regenerate the PHP autoload calling `composer dumpautoload` in RAP root directory.
+2 −2
Original line number Diff line number Diff line
@@ -98,8 +98,8 @@ class Locator {
        return new OAuth2RequestHandler($this);
    }

    public function getIdTokenBuilder(): IdTokenBuilder {
        return new IdTokenBuilder($this);
    public function getTokenBuilder(): TokenBuilder {
        return new TokenBuilder($this);
    }

    /**
+57 −41
Original line number Diff line number Diff line
@@ -43,7 +43,7 @@ class OAuth2RequestHandler {
        }

        // Storing OAuth2 data in session
        $oauth2Data = new \RAP\OAuth2Data();
        $oauth2Data = new OAuth2RequestData();
        $oauth2Data->clientId = $client->client;
        $oauth2Data->redirectUrl = $client->redirectUrl;
        $oauth2Data->state = $state;
@@ -55,34 +55,37 @@ class OAuth2RequestHandler {
        }

        $session = $this->locator->getSession();
        $session->setOAuth2Data($oauth2Data);
        $session->setOAuth2RequestData($oauth2Data);
    }

    public function getRedirectResponseUrl(): string {

        $session = $this->locator->getSession();

        $accessToken = new \RAP\AccessToken();
        $accessToken->code = base64_encode(bin2hex(openssl_random_pseudo_bytes(64)));
        $accessToken->token = base64_encode(bin2hex(openssl_random_pseudo_bytes(128)));
        $accessToken->userId = $session->getUser()->id;
        $accessToken->clientId = $session->getOAuth2Data()->clientId;
        $accessToken->redirectUri = $session->getOAuth2Data()->redirectUrl;
        $accessToken->scope = $session->getOAuth2Data()->scope;
        $code = base64_encode(bin2hex(openssl_random_pseudo_bytes(64)));

        $tokenData = new AccessTokenData();
        // Code is stored in hashed format inside the database, as a basic
        // security measure in order to prevent issues in case of data breach.
        $tokenData->codeHash = hash('sha256', $code);
        $tokenData->userId = $session->getUser()->id;
        $tokenData->clientId = $session->getOAuth2RequestData()->clientId;
        $tokenData->redirectUri = $session->getOAuth2RequestData()->redirectUrl;
        $tokenData->scope = $session->getOAuth2RequestData()->scope;

        $this->locator->getAccessTokenDAO()->createAccessToken($accessToken);
        $this->locator->getAccessTokenDAO()->createTokenData($tokenData);

        $state = $session->getOAuth2Data()->state;
        $nonce = $session->getOAuth2Data()->nonce;
        $state = $session->getOAuth2RequestData()->state;
        $nonce = $session->getOAuth2RequestData()->nonce;

        if ($state !== null) {
            // Authorization code grant flow
            $redirectUrl = $session->getOAuth2Data()->redirectUrl
                    . '?code=' . $accessToken->code . '&scope=profile&state=' . $state;
            $redirectUrl = $session->getOAuth2RequestData()->redirectUrl
                    . '?code=' . $code . '&scope=profile&state=' . $state;
        } else {
            // Implicit grant flow
            $idToken = $this->locator->getIdTokenBuilder()->getIdToken($accessToken, $nonce);
            $redirectUrl = $session->getOAuth2Data()->redirectUrl . "#id_token=" . $idToken;
            $idToken = $this->locator->getTokenBuilder()->getIdToken($tokenData, $nonce);
            $redirectUrl = $session->getOAuth2RequestData()->redirectUrl . "#id_token=" . $idToken;
        }

        return $redirectUrl;
@@ -99,19 +102,23 @@ class OAuth2RequestHandler {
        }

        // Note: theorically the standard wants also the client_id here,
        // however some clients don't send it
        // however some clients don't send it (e.g. Spring Security library)
        //
        $codeHash = hash('sha256', $params['code']);

        $accessToken = $this->locator->getAccessTokenDAO()->retrieveAccessTokenFromCode($params['code']);
        $tokenData = $this->locator->getAccessTokenDAO()->retrieveTokenDataFromCode($codeHash);

        if ($accessToken === null) {
        if ($tokenData === null) {
            throw new BadRequestException("No token for given code");
        }

        if ($accessToken->redirectUri !== $params['redirect_uri']) {
        if ($tokenData->redirectUri !== $params['redirect_uri']) {
            throw new BadRequestException("Invalid redirect URI: " . $params['redirect_uri']);
        }

        return $this->getAccessTokenResponse($accessToken);
        $response = $this->getAccessTokenResponse($tokenData);
        $this->locator->getAccessTokenDAO()->deleteTokenData($codeHash);
        return $response;
    }

    public function handleRefreshTokenRequest($params): array {
@@ -120,7 +127,7 @@ class OAuth2RequestHandler {
            throw new BadRequestException("refresh_token is required");
        }

        $refreshToken = $this->locator->getRefreshTokenDAO()->getRefreshToken($params['refresh_token']);
        $refreshToken = $this->locator->getRefreshTokenDAO()->getRefreshTokenData($params['refresh_token']);

        if ($refreshToken === null || $refreshToken->isExpired()) {
            throw new UnauthorizedException("Invalid refresh token");
@@ -129,7 +136,7 @@ class OAuth2RequestHandler {
        $scope = $this->getScope($params, $refreshToken);

        // Generating a new access token
        $accessToken = new AccessToken();
        $accessToken = new AccessTokenData();
        $accessToken->token = base64_encode(bin2hex(openssl_random_pseudo_bytes(128)));
        $accessToken->clientId = $refreshToken->clientId;
        $accessToken->userId = $refreshToken->userId;
@@ -171,36 +178,45 @@ class OAuth2RequestHandler {
        return $scope;
    }

    private function getAccessTokenResponse(AccessToken $accessToken) {
    private function getAccessTokenResponse(AccessTokenData $tokenData) {

        $result = [];
        $result['access_token'] = $accessToken->token;
        $result['access_token'] = $this->locator->getTokenBuilder()->getAccessToken($tokenData);
        $result['token_type'] = 'Bearer';
        $result['expires_in'] = $accessToken->expirationTime - time();
        $result['refresh_token'] = $this->getNewRefreshToken($accessToken);
        $result['expires_in'] = $tokenData->expirationTime - time();

        if ($accessToken->scope !== null && in_array('openid', $accessToken->scope)) {
            $result['id_token'] = $this->locator->getIdTokenBuilder()->getIdToken($accessToken);
        $refreshToken = base64_encode(bin2hex(openssl_random_pseudo_bytes(128)));
        $refreshTokenHash = hash('sha256', $refreshToken);
        $this->storeRefreshTokenData($tokenData, $refreshTokenHash);
        $result['refresh_token'] = $refreshToken;

        if ($tokenData->scope !== null && in_array('openid', $tokenData->scope)) {
            $result['id_token'] = $this->locator->getTokenBuilder()->getIdToken($tokenData);
        }

        return $result;
    }

    private function getNewRefreshToken(AccessToken $accessToken): string {

        $refreshToken = new RefreshToken();
        $refreshToken->token = base64_encode(bin2hex(openssl_random_pseudo_bytes(128)));
        $refreshToken->clientId = $accessToken->clientId;
        $refreshToken->userId = $accessToken->userId;
        $refreshToken->scope = $accessToken->scope;
    private function storeRefreshTokenData(AccessTokenData $accessTokenData, string $refreshTokenHash): void {

        $this->locator->getRefreshTokenDAO()->createRefreshToken($refreshToken);
        $refreshToken = new RefreshTokenData();
        $refreshToken->tokenHash = $refreshTokenHash;
        $refreshToken->clientId = $accessTokenData->clientId;
        $refreshToken->userId = $accessTokenData->userId;
        $refreshToken->scope = $accessTokenData->scope;

        return $refreshToken->token;
        $this->locator->getRefreshTokenDAO()->createRefreshTokenData($refreshToken);
    }

    /**
     * Token introspection endpoint shouldn't be necessary when using OIDC (since
     * tokens are self-contained JWT). This function is kept here for compatibility
     * with some libraries (e.g. Spring Security) but it could be removed in the
     * future.
     */
    public function handleCheckTokenRequest($token): array {

        // TODO: validate the token and expose data
        $accessToken = $this->locator->getAccessTokenDAO()->getAccessToken($token);
        if ($accessToken === null) {
            throw new UnauthorizedException("Invalid access token");
@@ -212,12 +228,12 @@ class OAuth2RequestHandler {
        $result['exp'] = $accessToken->expirationTime - time();
        $result['user_name'] = $user->id;
        $result['client_id'] = $accessToken->clientId;
        $result['refresh_token'] = $this->getNewRefreshToken($accessToken);
        $result['refresh_token'] = $this->storeRefreshTokenData($accessToken);

        if ($accessToken->scope !== null) {
            $result['scope'] = $accessToken->scope;
            if (in_array('openid', $accessToken->scope)) {
                $result['id_token'] = $this->locator->getIdTokenBuilder()->getIdToken($accessToken);
                $result['id_token'] = $this->locator->getTokenBuilder()->getIdToken($accessToken);
            }
        }

+122 −0
Original line number Diff line number Diff line
@@ -4,7 +4,7 @@ namespace RAP;

use \Firebase\JWT\JWT;

class IdTokenBuilder {
class TokenBuilder {

    private $locator;

@@ -12,36 +12,36 @@ class IdTokenBuilder {
        $this->locator = $locator;
    }

    public function getIdToken(AccessToken $accessToken, string $nonce = null): string {
    public function getIdToken(AccessTokenData $tokenData, string $nonce = null): string {

        $keyPair = $this->locator->getJWKSDAO()->getNewestKeyPair();

        $payload = $this->createPayloadArray($accessToken, $nonce);
        $payload = $this->createIdTokenPayloadArray($tokenData, $nonce);

        return JWT::encode($payload, $keyPair->privateKey, $keyPair->alg, $keyPair->keyId);
    }

    private function createPayloadArray(AccessToken $accessToken, string $nonce = null) {
    private function createIdTokenPayloadArray(AccessTokenData $tokenData, string $nonce = null) {

        $user = $this->locator->getUserDAO()->findUserById($accessToken->userId);
        $user = $this->locator->getUserDAO()->findUserById($tokenData->userId);

        $payloadArr = array(
            'iss' => $this->locator->config->jwtIssuer,
            'sub' => strval($user->id),
            'iat' => intval($accessToken->creationTime),
            'exp' => intval($accessToken->expirationTime),
            'iat' => intval($tokenData->creationTime),
            'exp' => intval($tokenData->expirationTime),
            'name' => $user->getCompleteName(),
            'aud' => $accessToken->clientId
            'aud' => $tokenData->clientId
        );

        if ($nonce !== null) {
            $payloadArr['nonce'] = $nonce;
        }

        if (in_array("email", $accessToken->scope)) {
        if (in_array("email", $tokenData->scope)) {
            $payloadArr['email'] = $user->getPrimaryEmail();
        }
        if (in_array("profile", $accessToken->scope)) {
        if (in_array("profile", $tokenData->scope)) {
            $payloadArr['given_name'] = $user->getName();
            $payloadArr['family_name'] = $user->getSurname();
            if ($user->getInstitution() !== null) {
@@ -49,18 +49,58 @@ class IdTokenBuilder {
            }
        }

        if ($accessToken->joinUser !== null) {
            $payloadArr['alt_sub'] = strval($accessToken->joinUser);
        }
        /*if ($tokenData->joinUser !== null) {
            $payloadArr['alt_sub'] = strval($tokenData->joinUser);
        }*/

        return $payloadArr;
    }

    public function getAccessToken(AccessTokenData $tokenData) {

        $keyPair = $this->locator->getJWKSDAO()->getNewestKeyPair();

        $user = $this->locator->getUserDAO()->findUserById($tokenData->userId);

        $payload = array(
            'iss' => $this->locator->config->jwtIssuer,
            'sub' => strval($user->id),
            'iat' => intval($tokenData->creationTime),
            'exp' => intval($tokenData->expirationTime),
            'aud' => $this->getAudience($tokenData)
        );

        return JWT::encode($payload, $keyPair->privateKey, $keyPair->alg, $keyPair->keyId);
    }

    private function getAudience(AccessTokenData $tokenData) {

        $client = $this->locator->getOAuth2ClientDAO()->getOAuth2ClientByClientId($tokenData->clientId);

        $audiences = [$tokenData->clientId];
        error_log(json_encode($client->scopeAudienceMap));

        foreach ($tokenData->scope as $scope) {
            if (array_key_exists($scope, $client->scopeAudienceMap)) {
                $audience = $client->scopeAudienceMap[$scope];
                if (!in_array($audience, $audiences)) {
                    array_push($audiences, $audience);
                }
            }
        }

        if (count($audiences) === 1) {
            // according to RFC 7519 audience can be a single value or an array
            return $audiences[0];
        }
        return $audiences;
    }

    /**
     * @param int $lifespan in hours
     * @param string $audit target service
     * @param string $audience target service
     */
    public function generateNewToken(int $lifespan, string $audit) {
    public function generateNewToken(int $lifespan, string $audience) {
        $keyPair = $this->locator->getJWKSDAO()->getNewestKeyPair();

        $user = $this->locator->getSession()->getUser();
@@ -73,7 +113,7 @@ class IdTokenBuilder {
            'sub' => strval($user->id),
            'iat' => $iat,
            'exp' => $exp,
            'aud' => $audit
            'aud' => $audience
        );

        return JWT::encode($payload, $keyPair->privateKey, $keyPair->alg, $keyPair->keyId);
+1 −1
Original line number Diff line number Diff line
@@ -125,7 +125,7 @@ class UserHandler {
        $accessToken->expirationTime = $accessToken->creationTime + 100;
        $accessToken->scope = ['openid'];

        return $this->locator->getIdTokenBuilder()->getIdToken($accessToken);
        return $this->locator->getTokenBuilder()->getIdToken($accessToken);
    }

}
Loading