Skip to content
Snippets Groups Projects
Commit 66d0f69b authored by Sonia Zorba's avatar Sonia Zorba
Browse files

Added support for Refresh Token

parent ec3ca777
No related branches found
No related tags found
No related merge requests found
......@@ -12,7 +12,7 @@ class IdTokenBuilder {
$this->locator = $locator;
}
public function getIdToken(AccessToken $accessToken, $nonce = null): string {
public function getIdToken(AccessToken $accessToken, string $nonce = null): string {
$keyPair = $this->locator->getJWKSDAO()->getNewestKeyPair();
......@@ -21,15 +21,15 @@ class IdTokenBuilder {
return JWT::encode($payload, $keyPair->privateKey, $keyPair->alg, $keyPair->keyId);
}
private function createPayloadArray(AccessToken $accessToken, $nonce = null) {
private function createPayloadArray(AccessToken $accessToken, string $nonce = null) {
$user = $this->locator->getUserDAO()->findUserById($accessToken->userId);
$payloadArr = array(
'iss' => $this->locator->config->jwtIssuer,
'sub' => $user->id,
'iat' => time(),
'exp' => time() + 3600,
'iat' => $accessToken->creationTime,
'exp' => $accessToken->expirationTime,
'name' => $user->getCompleteName(),
'aud' => $accessToken->clientId
);
......
......@@ -72,6 +72,16 @@ class Locator {
}
}
public function getRefreshTokenDAO(): RefreshTokenDAO {
$databaseConfig = $this->config->databaseConfig;
switch ($databaseConfig->dbtype) {
case 'MySQL':
return new MySQLRefreshTokenDAO($this);
default:
throw new \Exception($databaseConfig->dbtype . ' not supported yet');
}
}
public function getCallbackHandler(): CallbackHandler {
return new CallbackHandler($this);
}
......
......@@ -90,7 +90,16 @@ class OAuth2RequestHandler {
public function handleAccessTokenRequest($params): array {
$this->validateAccessTokenRequest($params);
if ($params['code'] === null) {
throw new BadRequestException("code id is required");
}
if ($params['redirect_uri'] === null) {
throw new BadRequestException("Redirect URI is required");
}
// Note: theorically the standard wants also the client_id here,
// however some clients don't send it
$accessToken = $this->locator->getAccessTokenDAO()->retrieveAccessTokenFromCode($params['code']);
......@@ -102,10 +111,73 @@ class OAuth2RequestHandler {
throw new BadRequestException("Invalid redirect URI: " . $params['redirect_uri']);
}
return $this->getAccessTokenResponse($accessToken);
}
public function handleRefreshTokenRequest($params): array {
if ($params['refresh_token'] === null) {
throw new BadRequestException("refresh_token is required");
}
$refreshToken = $this->locator->getRefreshTokenDAO()->getRefreshToken($params['refresh_token']);
if ($refreshToken === null || $refreshToken->isExpired()) {
throw new UnauthorizedException("Invalid refresh token");
}
$scope = $this->getScope($params, $refreshToken);
// Generating a new access token
$accessToken = new AccessToken();
$accessToken->token = base64_encode(bin2hex(openssl_random_pseudo_bytes(128)));
$accessToken->clientId = $refreshToken->clientId;
$accessToken->userId = $refreshToken->userId;
$accessToken->scope = $scope;
$accessToken = $this->locator->getAccessTokenDAO()->createAccessToken($accessToken);
return $this->getAccessTokenResponse($accessToken);
}
/**
* We can request a new access token with a scope that is a subset (or the
* same set) of the scope defined for the refresh token.
*/
private function getScope(array $params, RefreshToken $refreshToken): ?array {
$scope = $refreshToken->scope;
if ($params['scope'] !== null) {
$newScopeValues = explode(' ', $params['scope']);
foreach ($newScopeValues as $newScopeValue) {
$found = false;
foreach ($scope as $oldScopeValue) {
if ($oldScopeValue === $newScopeValue) {
$found = true;
break;
}
}
if (!$found) {
throw new BadRequestException("Scope " . $newScopeValue . " was not defined for the given refresh token");
}
}
$scope = $newScopeValues;
}
return $scope;
}
private function getAccessTokenResponse(AccessToken $accessToken) {
$result = [];
$result['access_token'] = $accessToken->token;
$result['token_type'] = 'Bearer';
$result['expires_in'] = $accessToken->expirationTime - time();
$result['refresh_token'] = $this->getNewRefreshToken($accessToken);
if ($accessToken->scope !== null && in_array('openid', $accessToken->scope)) {
$result['id_token'] = $this->locator->getIdTokenBuilder()->getIdToken($accessToken);
......@@ -114,24 +186,17 @@ class OAuth2RequestHandler {
return $result;
}
private function validateAccessTokenRequest($params) {
if ($params['grant_type'] === null) {
throw new BadRequestException("grant_type is required");
} else if ($params['grant_type'] !== 'authorization_code') {
throw new BadRequestException("grant_type must be authorization_code");
}
private function getNewRefreshToken(AccessToken $accessToken): string {
if ($params['code'] === null) {
throw new BadRequestException("code id is required");
}
$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;
if ($params['redirect_uri'] === null) {
throw new BadRequestException("Redirect URI is required");
}
$this->locator->getRefreshTokenDAO()->createRefreshToken($refreshToken);
// Note: theorically the standard wants also the client_id here,
// however some clients don't send it
return $refreshToken->token;
}
public function handleCheckTokenRequest($token): array {
......@@ -171,7 +236,7 @@ class OAuth2RequestHandler {
$accessToken = $this->locator->getAccessTokenDAO()->getAccessToken($bearer_token);
if ($accessToken === null) {
$this->attemptJWTTokenValidation($bearer_token);
} else if ($accessToken->expired) {
} else if ($accessToken->isExpired()) {
throw new UnauthorizedException("Access token is expired");
}
}
......
......@@ -15,11 +15,5 @@ interface AccessTokenDAO {
function getAccessToken(string $token): ?AccessToken;
/**
* Delete an access token from the database. This happens when the caller
* application has received the token and used it for retrieving user
* information from the token using the RAP REST web service.
* @param type $token login token
*/
function deleteAccessToken(string $token): void;
}
<?php
namespace RAP;
interface RefreshTokenDAO {
function createRefreshToken(RefreshToken $refreshToken): RefreshToken;
function getRefreshToken(string $token): ?RefreshToken;
function deleteRefreshToken(string $token): void;
}
......@@ -11,9 +11,8 @@ class MySQLAccessTokenDAO extends BaseMySQLDAO implements AccessTokenDAO {
public function createAccessToken(AccessToken $accessToken): AccessToken {
$dbh = $this->getDBHandler();
$stmt = $dbh->prepare("INSERT INTO access_token (token, code, user_id, redirect_uri, client_id, scope, expiration_time)"
. " VALUES(:token, :code, :user_id, :redirect_uri, :client_id, :scope, "
. " UNIX_TIMESTAMP(TIMESTAMPADD(HOUR, 1, CURRENT_TIMESTAMP)))");
$stmt = $dbh->prepare("INSERT INTO access_token (token, code, user_id, redirect_uri, client_id, scope, creation_time, expiration_time)"
. " VALUES(:token, :code, :user_id, :redirect_uri, :client_id, :scope, :creation_time, :expiration_time)");
$scope = null;
if ($accessToken->scope !== null) {
......@@ -26,7 +25,9 @@ class MySQLAccessTokenDAO extends BaseMySQLDAO implements AccessTokenDAO {
':user_id' => $accessToken->userId,
':redirect_uri' => $accessToken->redirectUri,
':client_id' => $accessToken->clientId,
':scope' => $scope
':scope' => $scope,
':creation_time' => $accessToken->creationTime,
':expiration_time' => $accessToken->expirationTime
);
if ($stmt->execute($params)) {
......@@ -42,8 +43,7 @@ class MySQLAccessTokenDAO extends BaseMySQLDAO implements AccessTokenDAO {
$dbh = $this->getDBHandler();
// Access token can be retrieved from code in 1 minute from the creation
$stmt = $dbh->prepare("SELECT token, code, user_id, redirect_uri, client_id, creation_time, expiration_time, scope,"
. " (expiration_time < UNIX_TIMESTAMP()) AS expired "
$stmt = $dbh->prepare("SELECT token, code, user_id, redirect_uri, client_id, creation_time, expiration_time, scope "
. " FROM access_token WHERE code = :code AND UNIX_TIMESTAMP() < (creation_time + 60)");
$stmt->bindParam(':code', $code);
......@@ -61,8 +61,7 @@ class MySQLAccessTokenDAO extends BaseMySQLDAO implements AccessTokenDAO {
$dbh = $this->getDBHandler();
$stmt = $dbh->prepare("SELECT token, code, user_id, redirect_uri, client_id, creation_time, expiration_time, scope,"
. " (expiration_time < UNIX_TIMESTAMP()) AS expired "
$stmt = $dbh->prepare("SELECT token, code, user_id, redirect_uri, client_id, creation_time, expiration_time, scope "
. " FROM access_token WHERE token = :token");
$stmt->bindParam(':token', $token);
......@@ -86,7 +85,6 @@ class MySQLAccessTokenDAO extends BaseMySQLDAO implements AccessTokenDAO {
$token->clientId = $row['client_id'];
$token->creationTime = $row['creation_time'];
$token->expirationTime = $row['expiration_time'];
$token->expired = $row['expired'] === "1";
$scope = null;
if (isset($row['scope'])) {
......
<?php
namespace RAP;
class MySQLRefreshTokenDAO extends BaseMySQLDAO implements RefreshTokenDAO {
public function __construct(Locator $locator) {
parent::__construct($locator);
}
function createRefreshToken(RefreshToken $refreshToken): RefreshToken {
$dbh = $this->getDBHandler();
$stmt = $dbh->prepare("INSERT INTO refresh_token (token, user_id, client_id, scope, creation_time, expiration_time)"
. " VALUES(:token, :user_id, :client_id, :scope, :creation_time, :expiration_time)");
$scope = null;
if ($refreshToken->scope !== null) {
$scope = join(' ', $refreshToken->scope);
}
$params = array(
':token' => $refreshToken->token,
':user_id' => $refreshToken->userId,
':client_id' => $refreshToken->clientId,
':scope' => $scope,
':creation_time' => $refreshToken->creationTime,
':expiration_time' => $refreshToken->expirationTime
);
if ($stmt->execute($params)) {
return $refreshToken;
} else {
error_log($stmt->errorInfo()[2]);
throw new \Exception("SQL error while storing user token");
}
}
function getRefreshToken(string $tokenValue): ?RefreshToken {
$dbh = $this->getDBHandler();
$stmt = $dbh->prepare("SELECT token, user_id, client_id, creation_time, expiration_time, scope "
. " FROM refresh_token WHERE token = :token");
$stmt->bindParam(':token', $tokenValue);
$stmt->execute();
$row = $stmt->fetch();
if (!$row) {
return null;
}
$token = new RefreshToken();
$token->token = $row['token'];
$token->userId = $row['user_id'];
$token->clientId = $row['client_id'];
$token->creationTime = $row['creation_time'];
$token->expirationTime = $row['expiration_time'];
$scope = null;
if (isset($row['scope'])) {
$scope = $row['scope'];
}
if ($scope !== null && $scope !== '') {
$token->scope = explode(' ', $scope);
}
return $token;
}
function deleteRefreshToken(string $token): void {
$dbh = $this->getDBHandler();
$stmt = $dbh->prepare("DELETE FROM refresh_token WHERE token = :token");
$stmt->bindParam(':token', $token);
$stmt->execute();
}
}
......@@ -4,14 +4,24 @@ namespace RAP;
class AccessToken {
private const TOKEN_LIFESPAN = 3600;
public function __construct() {
$this->creationTime = time();
$this->expirationTime = $this->creationTime + AccessToken::TOKEN_LIFESPAN;
}
public $token;
public $code;
public $userId;
public $creationTime;
public $expirationTime;
public $expired;
public $redirectUri;
public $clientId;
public $scope;
public function isExpired(): bool {
return $this->expirationTime < time();
}
}
<?php
namespace RAP;
class RefreshToken {
private const TOKEN_LIFESPAN = 2 * 3600;
public function __construct() {
$this->creationTime = time();
$this->expirationTime = $this->creationTime + RefreshToken::TOKEN_LIFESPAN;
}
public $token;
public $userId;
public $creationTime;
public $expirationTime;
public $expired;
public $clientId;
public $scope;
public function isExpired(): bool {
return $this->expirationTime < time();
}
}
# This directory is for programmatical execution of php scripts
# e.g. generating new keypairs from a cron job
Deny from all
<?php
chdir(dirname(__FILE__));
include '../include/init.php';
$handler = new \RAP\JWKSHandler($locator);
$handler->generateKeyPair();
echo "OK";
\ No newline at end of file
......@@ -98,11 +98,27 @@ Flight::route('POST /auth/oauth2/token', function() {
$params = [
"grant_type" => filter_input(INPUT_POST, "grant_type", FILTER_SANITIZE_STRING),
"code" => filter_input(INPUT_POST, "code", FILTER_SANITIZE_STRING),
"redirect_uri" => filter_input(INPUT_POST, "redirect_uri", 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)
];
if ($params['grant_type'] === null) {
throw new \RAP\BadRequestException("grant_type is required");
}
$requestHandler = new \RAP\OAuth2RequestHandler($locator);
switch ($params['grant_type']) {
case "authorization_code":
$token = $requestHandler->handleAccessTokenRequest($params);
break;
case "refresh_token":
$token = $requestHandler->handleRefreshTokenRequest($params);
break;
default:
throw new \RAP\BadRequestException("Unsupported grant type " . $params['grant_type']);
}
Flight::json($token);
});
......
......@@ -48,11 +48,22 @@ CREATE TABLE `access_token` (
`id` bigint(20) NOT NULL AUTO_INCREMENT,
`token` text NOT NULL,
`user_id` text NOT NULL,
`code` text NOT NULL,
`code` text,
`creation_time` BIGINT NOT NULL DEFAULT UNIX_TIMESTAMP(),
`expiration_time` BIGINT,
`expiration_time` BIGINT NOT NULL,
`redirect_uri` text,
`client_id` varchar(255),
`client_id` varchar(255) NOT NULL,
`scope` text,
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
CREATE TABLE `refresh_token` (
`id` bigint(20) NOT NULL AUTO_INCREMENT,
`token` text NOT NULL,
`user_id` text NOT NULL,
`creation_time` BIGINT NOT NULL DEFAULT UNIX_TIMESTAMP(),
`expiration_time` BIGINT NOT NULL,
`client_id` varchar(255) NOT NULL,
`scope` text,
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
......
......@@ -47,6 +47,7 @@ final class OAuth2RequestHandlerTest extends TestCase {
"redirect_uri" => "redirect_uri",
"state" => "state",
"alg" => null,
"nonce" => null,
"scope" => "email%20profile"
];
......
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Please register or to comment