diff options
Diffstat (limited to 'src')
33 files changed, 1006 insertions, 82 deletions
diff --git a/src/OAuth2/Controller/AuthorizeController.php b/src/OAuth2/Controller/AuthorizeController.php index 74d2423..e669824 100644 --- a/src/OAuth2/Controller/AuthorizeController.php +++ b/src/OAuth2/Controller/AuthorizeController.php @@ -78,6 +78,7 @@ class AuthorizeController implements AuthorizeControllerInterface $registered_redirect_uri = $clientData['redirect_uri']; } + // the user declined access to the client's application if ($is_authorized === false) { $redirect_uri = $this->redirect_uri ?: $registered_redirect_uri; $response->setRedirect($this->config['redirect_status_code'], $redirect_uri, $this->state, 'access_denied', "The user denied access to your application"); @@ -85,14 +86,10 @@ class AuthorizeController implements AuthorizeControllerInterface return; } - // @TODO: we should be explicit with this in the future - $params = array( - 'scope' => $this->scope, - 'state' => $this->state, - 'client_id' => $this->client_id, - 'redirect_uri' => $this->redirect_uri, - 'response_type' => $this->response_type, - ); + // build the parameters to set in the redirect URI + if (!$params = $this->buildAuthorizeParameters($request, $response, $user_id)) { + return; + } $authResult = $this->responseTypes[$this->response_type]->getAuthorizeResponse($params, $user_id); @@ -108,6 +105,24 @@ class AuthorizeController implements AuthorizeControllerInterface $response->setRedirect($this->config['redirect_status_code'], $uri); } + /* + * We have made this protected so this class can be extended to add/modify + * these parameters + */ + protected function buildAuthorizeParameters($request, $response, $user_id) + { + // @TODO: we should be explicit with this in the future + $params = array( + 'scope' => $this->scope, + 'state' => $this->state, + 'client_id' => $this->client_id, + 'redirect_uri' => $this->redirect_uri, + 'response_type' => $this->response_type, + ); + + return $params; + } + public function validateAuthorizeRequest(RequestInterface $request, ResponseInterface $response) { // Make sure a valid client id was supplied (we can not redirect because we were unable to verify the URI) @@ -168,7 +183,7 @@ class AuthorizeController implements AuthorizeControllerInterface $state = $request->query('state'); // type and client_id are required - if (!$response_type || !in_array($response_type, array(self::RESPONSE_TYPE_AUTHORIZATION_CODE, self::RESPONSE_TYPE_ACCESS_TOKEN))) { + if (!$response_type || !in_array($response_type, $this->getValidResponseTypes())) { $response->setRedirect($this->config['redirect_status_code'], $redirect_uri, $state, 'invalid_request', 'Invalid or missing response type', null); return false; @@ -189,9 +204,7 @@ class AuthorizeController implements AuthorizeControllerInterface return false; } - } - - if ($response_type == self::RESPONSE_TYPE_ACCESS_TOKEN) { + } else { if (!$this->config['allow_implicit']) { $response->setRedirect($this->config['redirect_status_code'], $redirect_uri, $state, 'unsupported_response_type', 'implicit grant type not supported', null); @@ -287,6 +300,14 @@ class AuthorizeController implements AuthorizeControllerInterface ; } + protected function getValidResponseTypes() + { + return array( + self::RESPONSE_TYPE_ACCESS_TOKEN, + self::RESPONSE_TYPE_AUTHORIZATION_CODE, + ); + } + /** * Internal method for validating redirect URI supplied * diff --git a/src/OAuth2/Controller/ResourceController.php b/src/OAuth2/Controller/ResourceController.php index 317d1d5..9623b9f 100644 --- a/src/OAuth2/Controller/ResourceController.php +++ b/src/OAuth2/Controller/ResourceController.php @@ -81,7 +81,7 @@ class ResourceController implements ResourceControllerInterface if (!$token = $this->tokenStorage->getAccessToken($token_param)) { $response->setError(401, 'invalid_token', 'The access token provided is invalid'); } elseif (!isset($token["expires"]) || !isset($token["client_id"])) { - $response->setError(401, 'invalid_token', 'Malformed token (missing "expires" or "client_id")'); + $response->setError(401, 'invalid_token', 'Malformed token (missing "expires")'); } elseif (time() > $token["expires"]) { $response->setError(401, 'invalid_token', 'The access token provided has expired'); } else { diff --git a/src/OAuth2/Encryption/EncryptionInterface.php b/src/OAuth2/Encryption/EncryptionInterface.php index dc8e0d2..2d336c6 100644 --- a/src/OAuth2/Encryption/EncryptionInterface.php +++ b/src/OAuth2/Encryption/EncryptionInterface.php @@ -6,4 +6,6 @@ interface EncryptionInterface { public function encode($payload, $key, $algorithm = null); public function decode($payload, $key, $algorithm = null); + public function urlSafeB64Encode($data); + public function urlSafeB64Decode($b64); } diff --git a/src/OAuth2/Encryption/Jwt.php b/src/OAuth2/Encryption/Jwt.php index 6bde68e..824ac13 100644 --- a/src/OAuth2/Encryption/Jwt.php +++ b/src/OAuth2/Encryption/Jwt.php @@ -10,11 +10,11 @@ class Jwt implements EncryptionInterface { public function encode($payload, $key, $algo = 'HS256') { - $header = array('typ' => 'JWT', 'alg' => $algo); + $header = $this->generateJwtHeader($payload, $algo); $segments = array( - $this->urlsafeB64Encode(json_encode($header)), - $this->urlsafeB64Encode(json_encode($payload)) + $this->urlSafeB64Encode(json_encode($header)), + $this->urlSafeB64Encode(json_encode($payload)) ); $signing_input = implode('.', $segments); @@ -39,15 +39,15 @@ class Jwt implements EncryptionInterface list($headb64, $payloadb64, $cryptob64) = $tks; - if (null === ($header = json_decode($this->urlsafeB64Decode($headb64), true))) { + if (null === ($header = json_decode($this->urlSafeB64Decode($headb64), true))) { return false; } - if (null === $payload = json_decode($this->urlsafeB64Decode($payloadb64), true)) { + if (null === $payload = json_decode($this->urlSafeB64Decode($payloadb64), true)) { return false; } - $sig = $this->urlsafeB64Decode($cryptob64); + $sig = $this->urlSafeB64Decode($cryptob64); if ($verify) { if (!isset($header['alg'])) { @@ -64,6 +64,7 @@ class Jwt implements EncryptionInterface private function verifySignature($signature, $input, $key, $algo = 'HS256') { + // use constants when possible, for HipHop support switch ($algo) { case'HS256': case'HS384': @@ -71,13 +72,13 @@ class Jwt implements EncryptionInterface return $this->sign($input, $key, $algo) === $signature; case 'RS256': - return openssl_verify($input, $signature, $key, 'sha256') === 1; + return openssl_verify($input, $signature, $key, defined('OPENSSL_ALGO_SHA256') ? OPENSSL_ALGO_SHA256 : 'sha256') === 1; case 'RS384': - return @openssl_verify($input, $signature, $key, 'sha384') === 1; + return @openssl_verify($input, $signature, $key, defined('OPENSSL_ALGO_SHA384') ? OPENSSL_ALGO_SHA384 : 'sha384') === 1; case 'RS512': - return @openssl_verify($input, $signature, $key, 'sha512') === 1; + return @openssl_verify($input, $signature, $key, defined('OPENSSL_ALGO_SHA512') ? OPENSSL_ALGO_SHA512 : 'sha512') === 1; default: throw new \InvalidArgumentException("Unsupported or invalid signing algorithm."); @@ -97,13 +98,13 @@ class Jwt implements EncryptionInterface return hash_hmac('sha512', $input, $key, true); case 'RS256': - return $this->generateRSASignature($input, $key, 'sha256'); + return $this->generateRSASignature($input, $key, defined('OPENSSL_ALGO_SHA256') ? OPENSSL_ALGO_SHA256 : 'sha256'); case 'RS384': - return $this->generateRSASignature($input, $key, 'sha384'); + return $this->generateRSASignature($input, $key, defined('OPENSSL_ALGO_SHA384') ? OPENSSL_ALGO_SHA384 : 'sha384'); case 'RS512': - return $this->generateRSASignature($input, $key, 'sha512'); + return $this->generateRSASignature($input, $key, defined('OPENSSL_ALGO_SHA512') ? OPENSSL_ALGO_SHA512 : 'sha512'); default: throw new \Exception("Unsupported or invalid signing algorithm."); @@ -119,17 +120,17 @@ class Jwt implements EncryptionInterface return $signature; } - private function urlSafeB64Encode($data) + public function urlSafeB64Encode($data) { $b64 = base64_encode($data); - $b64 = str_replace(array('+', '/', '\r', '\n', '='), + $b64 = str_replace(array('+', '/', "\r", "\n", '='), array('-', '_'), $b64); return $b64; } - private function urlSafeB64Decode($b64) + public function urlSafeB64Decode($b64) { $b64 = str_replace(array('-', '_'), array('+', '/'), @@ -137,4 +138,15 @@ class Jwt implements EncryptionInterface return base64_decode($b64); } + + /** + * Override to create a custom header + */ + protected function generateJwtHeader($payload, $algorithm) + { + return array( + 'typ' => 'JWT', + 'alg' => $algorithm, + ); + } } diff --git a/src/OAuth2/GrantType/GrantTypeInterface.php b/src/OAuth2/GrantType/GrantTypeInterface.php index e70bb23..98489e9 100644 --- a/src/OAuth2/GrantType/GrantTypeInterface.php +++ b/src/OAuth2/GrantType/GrantTypeInterface.php @@ -11,6 +11,7 @@ use OAuth2\ResponseInterface; */ interface GrantTypeInterface { + public function getQuerystringIdentifier(); public function validateRequest(RequestInterface $request, ResponseInterface $response); public function getClientId(); public function getUserId(); diff --git a/src/OAuth2/GrantType/JwtBearer.php b/src/OAuth2/GrantType/JwtBearer.php index 04ac144..534feae 100644 --- a/src/OAuth2/GrantType/JwtBearer.php +++ b/src/OAuth2/GrantType/JwtBearer.php @@ -5,6 +5,7 @@ namespace OAuth2\GrantType; use OAuth2\ClientAssertionType\ClientAssertionTypeInterface; use OAuth2\Storage\JwtBearerInterface; use OAuth2\Encryption\Jwt; +use OAuth2\Encryption\EncryptionInterface; use OAuth2\ResponseType\AccessTokenInterface; use OAuth2\RequestInterface; use OAuth2\ResponseInterface; @@ -35,7 +36,7 @@ class JwtBearer implements GrantTypeInterface, ClientAssertionTypeInterface * @param OAuth2\Encryption\JWT OPTIONAL $jwtUtil * The class used to decode, encode and verify JWTs. */ - public function __construct(JwtBearerInterface $storage, $audience, Jwt $jwtUtil = null) + public function __construct(JwtBearerInterface $storage, $audience, EncryptionInterface $jwtUtil = null) { $this->storage = $storage; $this->audience = $audience; diff --git a/src/OAuth2/OpenID/Controller/AuthorizeController.php b/src/OAuth2/OpenID/Controller/AuthorizeController.php new file mode 100644 index 0000000..4850ebe --- /dev/null +++ b/src/OAuth2/OpenID/Controller/AuthorizeController.php @@ -0,0 +1,86 @@ +<?php + +namespace OAuth2\OpenID\Controller; + +use OAuth2\Controller\AuthorizeController as BaseAuthorizeController; +use OAuth2\RequestInterface; +use OAuth2\ResponseInterface; + +/** + * @see OAuth2\Controller\AuthorizeControllerInterface + */ +class AuthorizeController extends BaseAuthorizeController implements AuthorizeControllerInterface +{ + private $nonce; + + protected function buildAuthorizeParameters($request, $response, $user_id) + { + if (!$params = parent::buildAuthorizeParameters($request, $response, $user_id)) { + return; + } + + // Generate an id token if needed. + if ($this->needsIdToken($this->getScope()) && $this->getResponseType() == self::RESPONSE_TYPE_AUTHORIZATION_CODE) { + $params['id_token'] = $this->responseTypes['id_token']->createIdToken($this->getClientId(), $user_id, $this->nonce); + } + + // add the nonce to return with the redirect URI + $params['nonce'] = $this->nonce; + + return $params; + } + + public function validateAuthorizeRequest(RequestInterface $request, ResponseInterface $response) + { + if (!parent::validateAuthorizeRequest($request, $response)) { + return false; + } + + $nonce = $request->query('nonce'); + + // Validate required nonce for "id_token" and "token id_token" + if (!$nonce && in_array($this->getResponseType(), array(self::RESPONSE_TYPE_ID_TOKEN, self::RESPONSE_TYPE_TOKEN_ID_TOKEN))) { + $response->setError(400, 'invalid_nonce', 'This application requires you specify a nonce parameter'); + + return false; + } + + $this->nonce = $nonce; + + return true; + } + + protected function getValidResponseTypes() + { + return array( + self::RESPONSE_TYPE_ACCESS_TOKEN, + self::RESPONSE_TYPE_AUTHORIZATION_CODE, + self::RESPONSE_TYPE_ID_TOKEN, + self::RESPONSE_TYPE_TOKEN_ID_TOKEN, + ); + } + + /** + * Returns whether the current request needs to generate an id token. + * + * ID Tokens are a part of the OpenID Connect specification, so this + * method checks whether OpenID Connect is enabled in the server settings + * and whether the openid scope was requested. + * + * @param $request_scope + * A space-separated string of scopes. + * + * @return + * TRUE if an id token is needed, FALSE otherwise. + */ + public function needsIdToken($request_scope) + { + // see if the "openid" scope exists in the requested scope + return $this->scopeUtil->checkScope('openid', $request_scope); + } + + public function getNonce() + { + return $this->nonce; + } +} diff --git a/src/OAuth2/OpenID/Controller/AuthorizeControllerInterface.php b/src/OAuth2/OpenID/Controller/AuthorizeControllerInterface.php new file mode 100644 index 0000000..af47cd0 --- /dev/null +++ b/src/OAuth2/OpenID/Controller/AuthorizeControllerInterface.php @@ -0,0 +1,12 @@ +<?php + +namespace OAuth2\OpenID\Controller; + +use OAuth2\RequestInterface; +use OAuth2\ResponseInterface; + +interface AuthorizeControllerInterface +{ + const RESPONSE_TYPE_ID_TOKEN = 'id_token'; + const RESPONSE_TYPE_TOKEN_ID_TOKEN = 'token id_token'; +} diff --git a/src/OAuth2/OpenID/Controller/UserInfoController.php b/src/OAuth2/OpenID/Controller/UserInfoController.php new file mode 100644 index 0000000..28e429e --- /dev/null +++ b/src/OAuth2/OpenID/Controller/UserInfoController.php @@ -0,0 +1,57 @@ +<?php + +namespace OAuth2\OpenID\Controller; + +use OAuth2\TokenType\TokenTypeInterface; +use OAuth2\Storage\AccessTokenInterface; +use OAuth2\OpenID\Storage\UserClaimsInterface; +use OAuth2\Controller\ResourceController; +use OAuth2\ScopeInterface; +use OAuth2\RequestInterface; +use OAuth2\ResponseInterface; + +/** + * @see OAuth2\Controller\UserInfoControllerInterface + */ +class UserInfoController extends ResourceController implements UserInfoControllerInterface +{ + private $token; + + protected $tokenType; + protected $tokenStorage; + protected $userClaimsStorage; + protected $config; + protected $scopeUtil; + + public function __construct(TokenTypeInterface $tokenType, AccessTokenInterface $tokenStorage, UserClaimsInterface $userClaimsStorage, $config = array(), ScopeInterface $scopeUtil = null) + { + $this->tokenType = $tokenType; + $this->tokenStorage = $tokenStorage; + $this->userClaimsStorage = $userClaimsStorage; + + $this->config = array_merge(array( + 'www_realm' => 'Service', + ), $config); + + if (is_null($scopeUtil)) { + $scopeUtil = new Scope(); + } + $this->scopeUtil = $scopeUtil; + } + + public function handleUserInfoRequest(RequestInterface $request, ResponseInterface $response) + { + if (!$this->verifyResourceRequest($request, $response, 'openid')) { + return; + } + + $token = $this->getToken(); + $claims = $this->userClaimsStorage->getUserClaims($token['user_id'], $token['scope']); + // The sub Claim MUST always be returned in the UserInfo Response. + // http://openid.net/specs/openid-connect-core-1_0.html#UserInfoResponse + $claims += array( + 'sub' => $token['user_id'], + ); + $response->setParameters($claims); + } +} diff --git a/src/OAuth2/OpenID/Controller/UserInfoControllerInterface.php b/src/OAuth2/OpenID/Controller/UserInfoControllerInterface.php new file mode 100644 index 0000000..a89049d --- /dev/null +++ b/src/OAuth2/OpenID/Controller/UserInfoControllerInterface.php @@ -0,0 +1,23 @@ +<?php + +namespace OAuth2\OpenID\Controller; + +use OAuth2\RequestInterface; +use OAuth2\ResponseInterface; + +/** + * This controller is called when the user claims for OpenID Connect's + * UserInfo endpoint should be returned. + * + * ex: + * > $response = new OAuth2\Response(); + * > $userInfoController->handleUserInfoRequest( + * > OAuth2\Request::createFromGlobals(), + * > $response; + * > $response->send(); + * + */ +interface UserInfoControllerInterface +{ + public function handleUserInfoRequest(RequestInterface $request, ResponseInterface $response); +} diff --git a/src/OAuth2/OpenID/GrantType/AuthorizationCode.php b/src/OAuth2/OpenID/GrantType/AuthorizationCode.php new file mode 100644 index 0000000..4a5da01 --- /dev/null +++ b/src/OAuth2/OpenID/GrantType/AuthorizationCode.php @@ -0,0 +1,36 @@ +<?php + +namespace OAuth2\OpenID\GrantType; + +use OAuth2\GrantType\AuthorizationCode as BaseAuthorizationCode; +use OAuth2\Storage\AuthorizationCodeInterface; +use OAuth2\ResponseType\AccessTokenInterface; +use OAuth2\RequestInterface; +use OAuth2\ResponseInterface; + +/** + * + * @author Brent Shaffer <bshafs at gmail dot com> + */ +class AuthorizationCode extends BaseAuthorizationCode +{ + public function createAccessToken(AccessTokenInterface $accessToken, $client_id, $user_id, $scope) + { + $includeRefreshToken = true; + if (isset($this->authCode['id_token'])) { + // OpenID Connect requests include the refresh token only if the + // offline_access scope has been requested and granted. + $scopes = explode(' ', trim($scope)); + $includeRefreshToken = in_array('offline_access', $scopes); + } + + $token = $accessToken->createAccessToken($client_id, $user_id, $scope, $includeRefreshToken); + if (isset($this->authCode['id_token'])) { + $token['id_token'] = $this->authCode['id_token']; + } + + $this->storage->expireAuthorizationCode($this->authCode['code']); + + return $token; + } +} diff --git a/src/OAuth2/OpenID/ResponseType/AuthorizationCode.php b/src/OAuth2/OpenID/ResponseType/AuthorizationCode.php new file mode 100644 index 0000000..8971954 --- /dev/null +++ b/src/OAuth2/OpenID/ResponseType/AuthorizationCode.php @@ -0,0 +1,60 @@ +<?php + +namespace OAuth2\OpenID\ResponseType; + +use OAuth2\ResponseType\AuthorizationCode as BaseAuthorizationCode; +use OAuth2\OpenID\Storage\AuthorizationCodeInterface as AuthorizationCodeStorageInterface; + +/** + * + * @author Brent Shaffer <bshafs at gmail dot com> + */ +class AuthorizationCode extends BaseAuthorizationCode implements AuthorizationCodeInterface +{ + public function __construct(AuthorizationCodeStorageInterface $storage, array $config = array()) + { + parent::__construct($storage, $config); + } + + public function getAuthorizeResponse($params, $user_id = null) + { + // build the URL to redirect to + $result = array('query' => array()); + + $params += array('scope' => null, 'state' => null, 'id_token' => null); + + $result['query']['code'] = $this->createAuthorizationCode($params['client_id'], $user_id, $params['redirect_uri'], $params['scope'], $params['id_token']); + + if (isset($params['state'])) { + $result['query']['state'] = $params['state']; + } + + return array($params['redirect_uri'], $result); + } + + /** + * Handle the creation of the authorization code. + * + * @param $client_id + * Client identifier related to the authorization code + * @param $user_id + * User ID associated with the authorization code + * @param $redirect_uri + * An absolute URI to which the authorization server will redirect the + * user-agent to when the end-user authorization step is completed. + * @param $scope + * (optional) Scopes to be stored in space-separated string. + * @param $id_token + * (optional) The OpenID Connect id_token. + * + * @see http://tools.ietf.org/html/rfc6749#section-4 + * @ingroup oauth2_section_4 + */ + public function createAuthorizationCode($client_id, $user_id, $redirect_uri, $scope = null, $id_token = null) + { + $code = $this->generateAuthorizationCode(); + $this->storage->setAuthorizationCode($code, $client_id, $user_id, $redirect_uri, time() + $this->config['auth_code_lifetime'], $scope, $id_token); + + return $code; + } +} diff --git a/src/OAuth2/OpenID/ResponseType/AuthorizationCodeInterface.php b/src/OAuth2/OpenID/ResponseType/AuthorizationCodeInterface.php new file mode 100644 index 0000000..7b8de7c --- /dev/null +++ b/src/OAuth2/OpenID/ResponseType/AuthorizationCodeInterface.php @@ -0,0 +1,32 @@ +<?php + +namespace OAuth2\OpenID\ResponseType; + +use OAuth2\ResponseType\AuthorizationCodeInterface as BaseAuthorizationCodeInterface; + +/** + * + * @author Brent Shaffer <bshafs at gmail dot com> + */ +interface AuthorizationCodeInterface extends BaseAuthorizationCodeInterface +{ + /** + * Handle the creation of the authorization code. + * + * @param $client_id + * Client identifier related to the authorization code + * @param $user_id + * User ID associated with the authorization code + * @param $redirect_uri + * An absolute URI to which the authorization server will redirect the + * user-agent to when the end-user authorization step is completed. + * @param $scope + * (optional) Scopes to be stored in space-separated string. + * @param $id_token + * (optional) The OpenID Connect id_token. + * + * @see http://tools.ietf.org/html/rfc6749#section-4 + * @ingroup oauth2_section_4 + */ + public function createAuthorizationCode($client_id, $user_id, $redirect_uri, $scope = null, $id_token = null); +} diff --git a/src/OAuth2/OpenID/ResponseType/IdToken.php b/src/OAuth2/OpenID/ResponseType/IdToken.php new file mode 100644 index 0000000..8d907bc --- /dev/null +++ b/src/OAuth2/OpenID/ResponseType/IdToken.php @@ -0,0 +1,125 @@ +<?php + +namespace OAuth2\OpenID\ResponseType; + +use OAuth2\Encryption\EncryptionInterface; +use OAuth2\Encryption\Jwt; +use OAuth2\Storage\RefreshTokenInterface; +use OAuth2\Storage\PublicKeyInterface; +use OAuth2\OpenID\Storage\UserClaimsInterface; + +class IdToken implements IdTokenInterface +{ + protected $userClaimsStorage; + protected $publicKeyStorage; + protected $config; + protected $encryptionUtil; + + public function __construct(UserClaimsInterface $userClaimsStorage, PublicKeyInterface $publicKeyStorage, array $config = array(), EncryptionInterface $encryptionUtil = null) + { + $this->userClaimsStorage = $userClaimsStorage; + $this->publicKeyStorage = $publicKeyStorage; + if (is_null($encryptionUtil)) { + $encryptionUtil = new Jwt(); + } + $this->encryptionUtil = $encryptionUtil; + + if (!isset($config['issuer'])) { + throw new \LogicException('config parameter "issuer" must be set'); + } + $this->config = array_merge(array( + 'id_lifetime' => 3600, + ), $config); + } + + public function getAuthorizeResponse($params, $userInfo = null) + { + // build the URL to redirect to + $result = array('query' => array()); + $params += array('scope' => null, 'state' => null, 'nonce' => null); + + // create the id token. + list($user_id, $auth_time) = $this->getUserIdAndAuthTime($userInfo); + $userClaims = $this->userClaimsStorage->getUserClaims($user_id, $params['scope']); + + $id_token = $this->createIdToken($params['client_id'], $userInfo, $params['nonce'], $userClaims, null); + $result["fragment"] = array('id_token' => $id_token); + if (isset($params['state'])) { + $result["fragment"]["state"] = $params['state']; + } + + return array($params['redirect_uri'], $result); + } + + public function createIdToken($client_id, $userInfo, $nonce = null, $userClaims = null, $access_token = null) + { + // pull auth_time from user info if supplied + list($user_id, $auth_time) = $this->getUserIdAndAuthTime($userInfo); + + $token = array( + 'iss' => $this->config['issuer'], + 'sub' => $user_id, + 'aud' => $client_id, + 'iat' => time(), + 'exp' => time() + $this->config['id_lifetime'], + 'auth_time' => $auth_time, + ); + + if ($nonce) { + $token['nonce'] = $nonce; + } + + if ($userClaims) { + $token += $userClaims; + } + + if ($access_token) { + $token['at_hash'] = $this->createAtHash($access_token, $client_id); + } + + return $this->encodeToken($token, $client_id); + } + + protected function createAtHash($access_token, $client_id = null) + { + // maps HS256 and RS256 to sha256, etc. + $algorithm = $this->publicKeyStorage->getEncryptionAlgorithm($client_id); + $hash_algorithm = 'sha' . substr($algorithm, 2); + $hash = hash($hash_algorithm, $access_token); + $at_hash = substr($hash, 0, strlen($hash) / 2); + + return $this->encryptionUtil->urlSafeB64Encode($at_hash); + } + + protected function encodeToken(array $token, $client_id = null) + { + $private_key = $this->publicKeyStorage->getPrivateKey($client_id); + $algorithm = $this->publicKeyStorage->getEncryptionAlgorithm($client_id); + + return $this->encryptionUtil->encode($token, $private_key, $algorithm); + } + + private function getUserIdAndAuthTime($userInfo) + { + $auth_time = null; + + // support an array for user_id / auth_time + if (is_array($userInfo)) { + if (!isset($userInfo['user_id'])) { + throw new \LogicException('if $user_id argument is an array, user_id index must be set'); + } + + $auth_time = isset($userInfo['auth_time']) ? $userInfo['auth_time'] : null; + $user_id = $userInfo['user_id']; + } else { + $user_id = $userInfo; + } + + if (is_null($auth_time)) { + $auth_time = time(); + } + + // userInfo is a scalar, and so this is the $user_id. Auth Time is null + return array($user_id, $auth_time); + } +} diff --git a/src/OAuth2/OpenID/ResponseType/IdTokenInterface.php b/src/OAuth2/OpenID/ResponseType/IdTokenInterface.php new file mode 100644 index 0000000..3dfb57e --- /dev/null +++ b/src/OAuth2/OpenID/ResponseType/IdTokenInterface.php @@ -0,0 +1,29 @@ +<?php + +namespace OAuth2\OpenID\ResponseType; + +use OAuth2\ResponseType\ResponseTypeInterface; + +interface IdTokenInterface extends ResponseTypeInterface +{ + /** + * Create the id token. + * + * If Authorization Code Flow is used, the id_token is generated when the + * authorization code is issued, and later returned from the token endpoint + * together with the access_token. + * If the Implicit Flow is used, the token and id_token are generated and + * returned together. + * + * @param string $client_id The client id. + * @param string $user_id The user id. + * @param string $nonce OPTIONAL The nonce. + * @param string $userClaims OPTIONAL Claims about the user. + * @param string $access_token OPTIONAL The access token, if known. + * + * @return string The ID Token represented as a JSON Web Token (JWT). + * + * @see http://openid.net/specs/openid-connect-core-1_0.html#IDToken + */ + public function createIdToken($client_id, $userInfo, $nonce = null, $userClaims = null, $access_token = null); +} diff --git a/src/OAuth2/OpenID/ResponseType/TokenIdToken.php b/src/OAuth2/OpenID/ResponseType/TokenIdToken.php new file mode 100644 index 0000000..67f2a4a --- /dev/null +++ b/src/OAuth2/OpenID/ResponseType/TokenIdToken.php @@ -0,0 +1,28 @@ +<?php + +namespace OAuth2\OpenID\ResponseType; + +use OAuth2\ResponseType\AccessTokenInterface; +use OAuth2\ResponseType\ResponseTypeInterface; + +class TokenIdToken implements TokenIdTokenInterface +{ + protected $accessToken; + protected $idToken; + + public function __construct(AccessTokenInterface $accessToken, IdToken $idToken) + { + $this->accessToken = $accessToken; + $this->idToken = $idToken; + } + + public function getAuthorizeResponse($params, $user_id = null) + { + $result = $this->accessToken->getAuthorizeResponse($params, $user_id); + $access_token = $result[1]['fragment']['access_token']; + $id_token = $this->idToken->createIdToken($params['client_id'], $user_id, $params['nonce'], null, $access_token); + $result[1]['fragment']['id_token'] = $id_token; + + return $result; + } +} diff --git a/src/OAuth2/OpenID/ResponseType/TokenIdTokenInterface.php b/src/OAuth2/OpenID/ResponseType/TokenIdTokenInterface.php new file mode 100644 index 0000000..1b4c513 --- /dev/null +++ b/src/OAuth2/OpenID/ResponseType/TokenIdTokenInterface.php @@ -0,0 +1,9 @@ +<?php + +namespace OAuth2\OpenID\ResponseType; + +use OAuth2\ResponseType\ResponseTypeInterface; + +interface TokenIdTokenInterface extends ResponseTypeInterface +{ +} diff --git a/src/OAuth2/OpenID/Storage/AuthorizationCodeInterface.php b/src/OAuth2/OpenID/Storage/AuthorizationCodeInterface.php new file mode 100644 index 0000000..3c6d00b --- /dev/null +++ b/src/OAuth2/OpenID/Storage/AuthorizationCodeInterface.php @@ -0,0 +1,44 @@ +<?php + +namespace OAuth2\OpenID\Storage; + +use OAuth2\Storage\AuthorizationCodeInterface as BaseAuthorizationCodeInterface; +/** + * Implement this interface to specify where the OAuth2 Server + * should get/save authorization codes for the "Authorization Code" + * grant type + * + * @author Brent Shaffer <bshafs at gmail dot com> + */ +interface AuthorizationCodeInterface extends BaseAuthorizationCodeInterface +{ + /** + * Take the provided authorization code values and store them somewhere. + * + * This function should be the storage counterpart to getAuthCode(). + * + * If storage fails for some reason, we're not currently checking for + * any sort of success/failure, so you should bail out of the script + * and provide a descriptive fail message. + * + * Required for OAuth2::GRANT_TYPE_AUTH_CODE. + * + * @param $code + * Authorization code to be stored. + * @param $client_id + * Client identifier to be stored. + * @param $user_id + * User identifier to be stored. + * @param string $redirect_uri + * Redirect URI(s) to be stored in a space-separated string. + * @param int $expires + * Expiration to be stored as a Unix timestamp. + * @param string $scope + * (optional) Scopes to be stored in space-separated string. + * @param string $id_token + * (optional) The OpenID Connect id_token. + * + * @ingroup oauth2_section_4 + */ + public function setAuthorizationCode($code, $client_id, $user_id, $redirect_uri, $expires, $scope = null, $id_token = null); +} diff --git a/src/OAuth2/OpenID/Storage/UserClaimsInterface.php b/src/OAuth2/OpenID/Storage/UserClaimsInterface.php new file mode 100644 index 0000000..f230bef --- /dev/null +++ b/src/OAuth2/OpenID/Storage/UserClaimsInterface.php @@ -0,0 +1,38 @@ +<?php + +namespace OAuth2\OpenID\Storage; + +/** + * Implement this interface to specify where the OAuth2 Server + * should retrieve user claims for the OpenID Connect id_token. + */ +interface UserClaimsInterface +{ + // valid scope values to pass into the user claims API call + const VALID_CLAIMS = 'profile email address phone'; + + // fields returned for the claims above + const PROFILE_CLAIM_VALUES = 'name family_name given_name middle_name nickname preferred_username profile picture website gender birthdate zoneinfo locale updated_at'; + const EMAIL_CLAIM_VALUES = 'email email_verified'; + const ADDRESS_CLAIM_VALUES = 'formatted street_address locality region postal_code country'; + const PHONE_CLAIM_VALUES = 'phone_number phone_number_verified'; + + /** + * Return claims about the provided user id. + * + * Groups of claims are returned based on the requested scopes. No group + * is required, and no claim is required. + * + * @param $user_id + * The id of the user for which claims should be returned. + * @param $scope + * The requested scope. + * Scopes with matching claims: profile, email, address, phone. + * + * @return + * An array in the claim => value format. + * + * @see http://openid.net/specs/openid-connect-core-1_0.html#ScopeClaims + */ + public function getUserClaims($user_id, $scope); +} diff --git a/src/OAuth2/Request.php b/src/OAuth2/Request.php index b49a42a..b37c591 100644 --- a/src/OAuth2/Request.php +++ b/src/OAuth2/Request.php @@ -80,7 +80,9 @@ class Request implements RequestInterface public function headers($name, $default = null) { - return isset($this->headers[$name]) ? $this->headers[$name] : $default; + $headers = array_change_key_case($this->headers); + $name = strtolower($name); + return isset($headers[$name]) ? $headers[$name] : $default; } public function getAllQueryParameters() @@ -149,7 +151,7 @@ class Request implements RequestInterface } elseif (isset($server['REDIRECT_HTTP_AUTHORIZATION'])) { $authorizationHeader = $server['REDIRECT_HTTP_AUTHORIZATION']; } elseif (function_exists('apache_request_headers')) { - $requestHeaders = apache_request_headers(); + $requestHeaders = (array) apache_request_headers(); // Server-side fix for bug in old Android versions (a nice side-effect of this fix means we don't care about capitalization for Authorization) $requestHeaders = array_combine(array_map('ucwords', array_keys($requestHeaders)), array_values($requestHeaders)); diff --git a/src/OAuth2/Response.php b/src/OAuth2/Response.php index c16b476..d746789 100644 --- a/src/OAuth2/Response.php +++ b/src/OAuth2/Response.php @@ -180,7 +180,9 @@ class Response implements ResponseInterface case 'xml': // this only works for single-level arrays $xml = new \SimpleXMLElement('<response/>'); - array_walk($this->parameters, array($xml, 'addChild')); + foreach ($this->parameters as $key => $param) { + $xml->addChild($param, $key); + } return $xml->asXML(); } diff --git a/src/OAuth2/ResponseType/AccessToken.php b/src/OAuth2/ResponseType/AccessToken.php index 078a457..0fb1121 100644 --- a/src/OAuth2/ResponseType/AccessToken.php +++ b/src/OAuth2/ResponseType/AccessToken.php @@ -122,7 +122,11 @@ class AccessToken implements AccessTokenInterface protected function generateAccessToken() { $tokenLen = 40; - if (@file_exists('/dev/urandom')) { // Get 100 bytes of random data + if (function_exists('mcrypt_create_iv')) { + $randomData = mcrypt_create_iv(100, MCRYPT_DEV_URANDOM); + } else if (function_exists('openssl_random_pseudo_bytes')) { + $randomData = openssl_random_pseudo_bytes(100); + } else if (@file_exists('/dev/urandom')) { // Get 100 bytes of random data $randomData = file_get_contents('/dev/urandom', false, null, 0, 100) . uniqid(mt_rand(), true); } else { $randomData = mt_rand() . mt_rand() . mt_rand() . mt_rand() . microtime(true) . uniqid(mt_rand(), true); diff --git a/src/OAuth2/ResponseType/AuthorizationCode.php b/src/OAuth2/ResponseType/AuthorizationCode.php index c39d6a0..51bd437 100644 --- a/src/OAuth2/ResponseType/AuthorizationCode.php +++ b/src/OAuth2/ResponseType/AuthorizationCode.php @@ -29,10 +29,10 @@ class AuthorizationCode implements AuthorizationCodeInterface $params += array('scope' => null, 'state' => null); - $result["query"]["code"] = $this->createAuthorizationCode($params['client_id'], $user_id, $params['redirect_uri'], $params['scope']); + $result['query']['code'] = $this->createAuthorizationCode($params['client_id'], $user_id, $params['redirect_uri'], $params['scope']); if (isset($params['state'])) { - $result["query"]["state"] = $params['state']; + $result['query']['state'] = $params['state']; } return array($params['redirect_uri'], $result); @@ -85,7 +85,11 @@ class AuthorizationCode implements AuthorizationCodeInterface protected function generateAuthorizationCode() { $tokenLen = 40; - if (file_exists('/dev/urandom')) { // Get 100 bytes of random data + if (function_exists('mcrypt_create_iv')) { + $randomData = mcrypt_create_iv(100, MCRYPT_DEV_URANDOM); + } else if (function_exists('openssl_random_pseudo_bytes')) { + $randomData = openssl_random_pseudo_bytes(100); + } else if (@file_exists('/dev/urandom')) { // Get 100 bytes of random data $randomData = file_get_contents('/dev/urandom', false, null, 0, 100) . uniqid(mt_rand(), true); } else { $randomData = mt_rand() . mt_rand() . mt_rand() . mt_rand() . microtime(true) . uniqid(mt_rand(), true); diff --git a/src/OAuth2/Scope.php b/src/OAuth2/Scope.php index 1e2e339..5c792bb 100644 --- a/src/OAuth2/Scope.php +++ b/src/OAuth2/Scope.php @@ -62,7 +62,18 @@ class Scope implements ScopeInterface */ public function scopeExists($scope) { - return $this->storage->scopeExists($scope); + // Check reserved scopes first. + $scope = explode(' ', trim($scope)); + $reservedScope = $this->getReservedScopes(); + $nonReservedScopes = array_diff($scope, $reservedScope); + if (count($nonReservedScopes) == 0) { + return true; + } + else { + // Check the storage for non-reserved scopes. + $nonReservedScopes = implode(' ', $nonReservedScopes); + return $this->storage->scopeExists($nonReservedScopes); + } } public function getScopeFromRequest(RequestInterface $request) @@ -75,4 +86,18 @@ class Scope implements ScopeInterface { return $this->storage->getDefaultScope($client_id); } + + /** + * Get reserved scopes needed by the server. + * + * In case OpenID Connect is used, these scopes must include: + * 'openid', offline_access'. + * + * @return + * An array of reserved scopes. + */ + public function getReservedScopes() + { + return array('openid', 'offline_access'); + } } diff --git a/src/OAuth2/Server.php b/src/OAuth2/Server.php index f07b5fb..b138a55 100644 --- a/src/OAuth2/Server.php +++ b/src/OAuth2/Server.php @@ -4,6 +4,12 @@ namespace OAuth2; use OAuth2\Controller\ResourceControllerInterface; use OAuth2\Controller\ResourceController; +use OAuth2\OpenID\Controller\UserInfoControllerInterface; +use OAuth2\OpenID\Controller\UserInfoController; +use OAuth2\OpenID\Controller\AuthorizeController as OpenIDAuthorizeController; +use OAuth2\OpenID\ResponseType\AuthorizationCode as OpenIDAuthorizationCodeResponseType; +use OAuth2\OpenID\Storage\AuthorizationCodeInterface as OpenIDAuthorizationCodeInterface; +use OAuth2\OpenID\GrantType\AuthorizationCode as OpenIDAuthorizationCodeGrantType; use OAuth2\Controller\AuthorizeControllerInterface; use OAuth2\Controller\AuthorizeController; use OAuth2\Controller\TokenControllerInterface; @@ -14,6 +20,8 @@ use OAuth2\ResponseType\ResponseTypeInterface; use OAuth2\ResponseType\AuthorizationCode as AuthorizationCodeResponseType; use OAuth2\ResponseType\AccessToken; use OAuth2\ResponseType\CryptoToken; +use OAuth2\OpenID\ResponseType\IdToken; +use OAuth2\OpenID\ResponseType\TokenIdToken; use OAuth2\TokenType\TokenTypeInterface; use OAuth2\TokenType\Bearer; use OAuth2\GrantType\GrantTypeInterface; @@ -34,7 +42,8 @@ use OAuth2\Storage\CryptoTokenInterface; */ class Server implements ResourceControllerInterface, AuthorizeControllerInterface, - TokenControllerInterface + TokenControllerInterface, + UserInfoControllerInterface { // misc properties protected $response; @@ -45,6 +54,7 @@ class Server implements ResourceControllerInterface, protected $authorizeController; protected $tokenController; protected $resourceController; + protected $userInfoController; // config classes protected $grantTypes; @@ -60,6 +70,7 @@ class Server implements ResourceControllerInterface, 'client' => 'OAuth2\Storage\ClientInterface', 'refresh_token' => 'OAuth2\Storage\RefreshTokenInterface', 'user_credentials' => 'OAuth2\Storage\UserCredentialsInterface', + 'user_claims' => 'OAuth2\OpenID\Storage\UserClaimsInterface', 'public_key' => 'OAuth2\Storage\PublicKeyInterface', 'jwt_bearer' => 'OAuth2\Storage\JWTBearerInterface', 'scope' => 'OAuth2\Storage\ScopeInterface', @@ -67,6 +78,8 @@ class Server implements ResourceControllerInterface, protected $responseTypeMap = array( 'token' => 'OAuth2\ResponseType\AccessTokenInterface', 'code' => 'OAuth2\ResponseType\AuthorizationCodeInterface', + 'id_token' => 'OAuth2\OpenID\ResponseType\IdTokenInterface', + 'token id_token' => 'OAuth2\OpenID\ResponseType\TokenIdTokenInterface', ); /** @@ -101,6 +114,8 @@ class Server implements ResourceControllerInterface, $this->config = array_merge(array( 'use_crypto_tokens' => false, 'store_encrypted_token_string' => true, + 'use_openid_connect' => false, + 'id_lifetime' => 3600, 'access_lifetime' => 3600, 'www_realm' => 'Service', 'token_param_name' => 'access_token', @@ -151,6 +166,15 @@ class Server implements ResourceControllerInterface, return $this->resourceController; } + public function getUserInfoController() + { + if (is_null($this->userInfoController)) { + $this->userInfoController = $this->createDefaultUserInfoController(); + } + + return $this->userInfoController; + } + /** * every getter deserves a setter */ @@ -176,6 +200,37 @@ class Server implements ResourceControllerInterface, } /** + * every getter deserves a setter + */ + public function setUserInfoController(UserInfoControllerInterface $userInfoController) + { + $this->userInfoController = $userInfoController; + } + + /** + * Return claims about the authenticated end-user. + * This would be called from the "/UserInfo" endpoint as defined in the spec. + * + * @param $request - OAuth2\RequestInterface + * Request object to grant access token + * + * @param $response - OAuth2\ResponseInterface + * Response object containing error messages (failure) or user claims (success) + * + * @throws InvalidArgumentException + * @throws LogicException + * + * @see http://openid.net/specs/openid-connect-core-1_0.html#UserInfo + */ + public function handleUserInfoRequest(RequestInterface $request, ResponseInterface $response = null) + { + $this->response = is_null($response) ? new Response() : $response; + $this->getUserInfoController()->handleUserInfoRequest($request, $this->response); + + return $this->response; + } + + /** * Grant or deny a requested access token. * This would be called from the "/token" endpoint as defined in the spec. * Obviously, you can call your endpoint whatever you want. @@ -295,7 +350,7 @@ class Server implements ResourceControllerInterface, if (is_string($key)) { $this->grantTypes[$key] = $grantType; } else { - $this->grantTypes[] = $grantType; + $this->grantTypes[$grantType->getQuerystringIdentifier()] = $grantType; } // persist added grant type down to TokenController @@ -318,7 +373,7 @@ class Server implements ResourceControllerInterface, { // if explicitly set to a valid key, do not "magically" set below if (isset($this->storageMap[$key])) { - if (!$storage instanceof $this->storageMap[$key]) { + if (!is_null($storage) && !$storage instanceof $this->storageMap[$key]) { throw new \InvalidArgumentException(sprintf('storage of type "%s" must implement interface "%s"', $key, $this->storageMap[$key])); } $this->storages[$key] = $storage; @@ -400,8 +455,19 @@ class Server implements ResourceControllerInterface, if (0 == count($this->responseTypes)) { $this->responseTypes = $this->getDefaultResponseTypes(); } + if ($this->config['use_openid_connect'] && !isset($this->responseTypes['id_token'])) { + $this->responseTypes['id_token'] = $this->createDefaultIdTokenResponseType(); + if ($this->config['allow_implicit']) { + $this->responseTypes['token id_token'] = $this->createDefaultTokenIdTokenResponseType(); + } + } + $config = array_intersect_key($this->config, array_flip(explode(' ', 'allow_implicit enforce_state require_exact_redirect_uri'))); + if ($this->config['use_openid_connect']) { + return new OpenIDAuthorizeController($this->storages['client'], $this->responseTypes, $config, $this->getScopeUtil()); + } + return new AuthorizeController($this->storages['client'], $this->responseTypes, $config, $this->getScopeUtil()); } @@ -429,11 +495,7 @@ class Server implements ResourceControllerInterface, throw new \LogicException("You must supply a storage object implementing OAuth2\Storage\ClientInterface to use the token server"); } - if ($this->config['use_crypto_tokens']) { - $accessTokenResponseType = $this->getCryptoTokenResponseType(); - } else { - $accessTokenResponseType = $this->getAccessTokenResponseType(); - } + $accessTokenResponseType = $this->getAccessTokenResponseType(); return new TokenController($accessTokenResponseType, $this->storages['client'], $this->grantTypes, $this->clientAssertionType, $this->getScopeUtil()); } @@ -458,6 +520,30 @@ class Server implements ResourceControllerInterface, return new ResourceController($this->tokenType, $this->storages['access_token'], $config, $this->getScopeUtil()); } + protected function createDefaultUserInfoController() + { + if ($this->config['use_crypto_tokens']) { + // overwrites access token storage with crypto token storage if "use_crypto_tokens" is set + if (!isset($this->storages['access_token']) || !$this->storages['access_token'] instanceof CryptoTokenInterface) { + $this->storages['access_token'] = $this->createDefaultCryptoTokenStorage(); + } + } elseif (!isset($this->storages['access_token'])) { + throw new \LogicException("You must supply a storage object implementing OAuth2\Storage\AccessTokenInterface or use CryptoTokens to use the UserInfo server"); + } + + if (!isset($this->storages['user_claims'])) { + throw new \LogicException("You must supply a storage object implementing OAuth2\OpenID\Storage\UserClaimsInterface to use the UserInfo server"); + } + + if (!$this->tokenType) { + $this->tokenType = $this->getDefaultTokenType(); + } + + $config = array_intersect_key($this->config, array('www_realm' => '')); + + return new UserInfoController($this->tokenType, $this->storages['access_token'], $this->storages['user_claims'], $config, $this->getScopeUtil()); + } + protected function getDefaultTokenType() { $config = array_intersect_key($this->config, array_flip(explode(' ', 'token_param_name token_bearer_header_name'))); @@ -470,16 +556,26 @@ class Server implements ResourceControllerInterface, $responseTypes = array(); if ($this->config['allow_implicit']) { - if ($this->config['use_crypto_tokens']) { - $responseTypes['token'] = $this->getCryptoTokenResponseType(); - } elseif (isset($this->storages['access_token'])) { - $responseTypes['token'] = $this->getAccessTokenResponseType(); + $responseTypes['token'] = $this->getAccessTokenResponseType(); + } + + if ($this->config['use_openid_connect']) { + $responseTypes['id_token'] = $this->getIdTokenResponseType(); + if ($this->config['allow_implicit']) { + $responseTypes['token id_token'] = $this->getTokenIdTokenResponseType(); } } if (isset($this->storages['authorization_code'])) { $config = array_intersect_key($this->config, array_flip(explode(' ', 'enforce_redirect auth_code_lifetime'))); - $responseTypes['code'] = new AuthorizationCodeResponseType($this->storages['authorization_code'], $config); + if ($this->config['use_openid_connect']) { + if (!$this->storages['authorization_code'] instanceof OpenIDAuthorizationCodeInterface) { + throw new \LogicException("Your authorization_code storage must implement OAuth2\OpenID\Storage\AuthorizationCodeInterface to work when 'use_openid_connect' is true"); + } + $responseTypes['code'] = new OpenIDAuthorizationCodeResponseType($this->storages['authorization_code'], $config); + } else { + $responseTypes['code'] = new AuthorizationCodeResponseType($this->storages['authorization_code'], $config); + } } if (count($responseTypes) == 0) { @@ -508,7 +604,14 @@ class Server implements ResourceControllerInterface, } if (isset($this->storages['authorization_code'])) { - $grantTypes['authorization_code'] = new AuthorizationCode($this->storages['authorization_code']); + if ($this->config['use_openid_connect']) { + if (!$this->storages['authorization_code'] instanceof OpenIDAuthorizationCodeInterface) { + throw new \LogicException("Your authorization_code storage must implement OAuth2\OpenID\Storage\AuthorizationCodeInterface to work when 'use_openid_connect' is true"); + } + $grantTypes['authorization_code'] = new OpenIDAuthorizationCodeGrantType($this->storages['authorization_code']); + } else { + $grantTypes['authorization_code'] = new AuthorizationCode($this->storages['authorization_code']); + } } if (count($grantTypes) == 0) { @@ -524,16 +627,29 @@ class Server implements ResourceControllerInterface, return $this->responseTypes['token']; } + if ($this->config['use_crypto_tokens']) { + return $this->createDefaultCryptoTokenResponseType(); + } + return $this->createDefaultAccessTokenResponseType(); } - protected function getCryptoTokenResponseType() + protected function getIdTokenResponseType() { - if (isset($this->responseTypes['token'])) { - return $this->responseTypes['token']; + if (isset($this->responseTypes['id_token'])) { + return $this->responseTypes['id_token']; + } + + return $this->createDefaultIdTokenResponseType(); + } + + protected function getTokenIdTokenResponseType() + { + if (isset($this->responseTypes['token id_token'])) { + return $this->responseTypes['token id_token']; } - return $this->createDefaultCryptoTokenResponseType(); + return $this->createDefaultTokenIdTokenResponseType(); } /** @@ -593,6 +709,24 @@ class Server implements ResourceControllerInterface, return new AccessToken($this->storages['access_token'], $refreshStorage, $config); } + protected function createDefaultIdTokenResponseType() + { + if (!isset($this->storages['user_claims'])) { + throw new \LogicException("You must supply a storage object implementing OAuth2\OpenID\Storage\UserClaimsInterface to use openid connect"); + } + if (!isset($this->storages['public_key'])) { + throw new \LogicException("You must supply a storage object implementing OAuth2\Storage\PublicKeyInterface to use openid connect"); + } + + $config = array_intersect_key($this->config, array_flip(explode(' ', 'issuer id_lifetime'))); + return new IdToken($this->storages['user_claims'], $this->storages['public_key'], $config); + } + + protected function createDefaultTokenIdTokenResponseType() + { + return new TokenIdToken($this->getAccessTokenResponseType(), $this->getIdTokenResponseType()); + } + public function getResponse() { return $this->response; diff --git a/src/OAuth2/Storage/AccessTokenInterface.php b/src/OAuth2/Storage/AccessTokenInterface.php index dd687e4..b55c3d6 100644 --- a/src/OAuth2/Storage/AccessTokenInterface.php +++ b/src/OAuth2/Storage/AccessTokenInterface.php @@ -21,10 +21,11 @@ interface AccessTokenInterface * @return * An associative array as below, and return NULL if the supplied oauth_token * is invalid: - * - client_id: Stored client identifier. * - expires: Stored expiration in unix timestamp. + * - client_id: (optional) Stored client identifier. * - user_id: (optional) Stored user identifier. * - scope: (optional) Stored scope values in space-separated string. + * - id_token: (optional) Stored id_token (if "use_openid_connect" is true). * * @ingroup oauth2_section_7 */ diff --git a/src/OAuth2/Storage/Cassandra.php b/src/OAuth2/Storage/Cassandra.php index b63a752..f6d1e05 100644 --- a/src/OAuth2/Storage/Cassandra.php +++ b/src/OAuth2/Storage/Cassandra.php @@ -1,9 +1,11 @@ <?php namespace OAuth2\Storage; + use phpcassa\ColumnFamily; use phpcassa\ColumnSlice; use phpcassa\Connection\ConnectionPool; +use OAuth2\OpenID\Storage\AuthorizationCodeInterface as OpenIDAuthorizationCodeInterface; /** * Cassandra storage for all storage types @@ -32,7 +34,8 @@ class Cassandra implements AuthorizationCodeInterface, UserCredentialsInterface, RefreshTokenInterface, JwtBearerInterface, - ScopeInterface + ScopeInterface, + OpenIDAuthorizationCodeInterface { private $cache; @@ -145,11 +148,11 @@ class Cassandra implements AuthorizationCodeInterface, return $this->getValue($this->config['code_key'] . $code); } - public function setAuthorizationCode($authorization_code, $client_id, $user_id, $redirect_uri, $expires, $scope = null) + public function setAuthorizationCode($authorization_code, $client_id, $user_id, $redirect_uri, $expires, $scope = null, $id_token = null) { return $this->setValue( $this->config['code_key'] . $authorization_code, - compact('authorization_code', 'client_id', 'user_id', 'redirect_uri', 'expires', 'scope'), + compact('authorization_code', 'client_id', 'user_id', 'redirect_uri', 'expires', 'scope', 'id_token'), $expires ); } diff --git a/src/OAuth2/Storage/CryptoToken.php b/src/OAuth2/Storage/CryptoToken.php index 6cad23b..f107e45 100644 --- a/src/OAuth2/Storage/CryptoToken.php +++ b/src/OAuth2/Storage/CryptoToken.php @@ -61,7 +61,7 @@ class CryptoToken implements CryptoTokenInterface $algorithm = $this->publicKeyStorage->getEncryptionAlgorithm($client_id); // now that we have the client_id, verify the token - if (false === $this->encryptionUtil->decode($oauth_token, $public_key, $algorithm)) { + if (false === $this->encryptionUtil->decode($oauth_token, $public_key, true)) { return false; } diff --git a/src/OAuth2/Storage/Memory.php b/src/OAuth2/Storage/Memory.php index 2c9dc3d..35e6cba 100644 --- a/src/OAuth2/Storage/Memory.php +++ b/src/OAuth2/Storage/Memory.php @@ -2,6 +2,9 @@ namespace OAuth2\Storage; +use OAuth2\OpenID\Storage\UserClaimsInterface; +use OAuth2\OpenID\Storage\AuthorizationCodeInterface as OpenIDAuthorizationCodeInterface; + /** * Simple in-memory storage for all storage types * @@ -12,12 +15,14 @@ namespace OAuth2\Storage; */ class Memory implements AuthorizationCodeInterface, UserCredentialsInterface, + UserClaimsInterface, AccessTokenInterface, ClientCredentialsInterface, RefreshTokenInterface, JwtBearerInterface, ScopeInterface, - PublicKeyInterface + PublicKeyInterface, + OpenIDAuthorizationCodeInterface { public $authorizationCodes; public $userCredentials; @@ -69,9 +74,9 @@ class Memory implements AuthorizationCodeInterface, ), $this->authorizationCodes[$code]); } - public function setAuthorizationCode($code, $client_id, $user_id, $redirect_uri, $expires, $scope = null) + public function setAuthorizationCode($code, $client_id, $user_id, $redirect_uri, $expires, $scope = null, $id_token = null) { - $this->authorizationCodes[$code] = compact('code', 'client_id', 'user_id', 'redirect_uri', 'expires', 'scope'); + $this->authorizationCodes[$code] = compact('code', 'client_id', 'user_id', 'redirect_uri', 'expires', 'scope', 'id_token'); return true; } @@ -119,6 +124,45 @@ class Memory implements AuthorizationCodeInterface, ), $this->userCredentials[$username]); } + /* UserClaimsInterface */ + public function getUserClaims($user_id, $claims) + { + if (!$userDetails = $this->getUserDetails($user_id)) { + return false; + } + + $claims = explode(' ', trim($claims)); + $userClaims = array(); + + // for each requested claim, if the user has the claim, set it in the response + $validClaims = explode(' ', self::VALID_CLAIMS); + foreach ($validClaims as $validClaim) { + if (in_array($validClaim, $claims)) { + if ($validClaim == 'address') { + // address is an object with subfields + $userClaims['address'] = $this->getUserClaim($validClaim, $userDetails['address'] ?: $userDetails); + } else { + $userClaims = array_merge($this->getUserClaim($validClaim, $userDetails)); + } + } + } + + return $userClaims; + } + + protected function getUserClaim($claim, $userDetails) + { + $userClaims = array(); + $claimValuesString = constant(sprintf('self::%s_CLAIM_VALUES', strtoupper($claim))); + $claimValues = explode(' ', $claimValuesString); + + foreach ($claimValues as $value) { + $userClaims[$value] = isset($userDetails[$value]) ? $userDetails[$value] : null; + } + + return $userClaims; + } + /* ClientCredentialsInterface */ public function checkClientCredentials($client_id, $client_secret = null) { @@ -206,9 +250,9 @@ class Memory implements AuthorizationCodeInterface, return isset($this->accessTokens[$access_token]) ? $this->accessTokens[$access_token] : false; } - public function setAccessToken($access_token, $client_id, $user_id, $expires, $scope = null) + public function setAccessToken($access_token, $client_id, $user_id, $expires, $scope = null, $id_token = null) { - $this->accessTokens[$access_token] = compact('access_token', 'client_id', 'user_id', 'expires', 'scope'); + $this->accessTokens[$access_token] = compact('access_token', 'client_id', 'user_id', 'expires', 'scope', 'id_token'); return true; } diff --git a/src/OAuth2/Storage/Mongo.php b/src/OAuth2/Storage/Mongo.php index 60db771..87935fe 100644 --- a/src/OAuth2/Storage/Mongo.php +++ b/src/OAuth2/Storage/Mongo.php @@ -2,6 +2,8 @@ namespace OAuth2\Storage; +use OAuth2\OpenID\Storage\AuthorizationCodeInterface as OpenIDAuthorizationCodeInterface; + /** * Simple MongoDB storage for all storage types * @@ -19,7 +21,8 @@ class Mongo implements AuthorizationCodeInterface, ClientCredentialsInterface, UserCredentialsInterface, RefreshTokenInterface, - JwtBearerInterface + JwtBearerInterface, + OpenIDAuthorizationCodeInterface { protected $db; protected $config; @@ -171,7 +174,7 @@ class Mongo implements AuthorizationCodeInterface, return is_null($code) ? false : $code; } - public function setAuthorizationCode($code, $client_id, $user_id, $redirect_uri, $expires, $scope = null) + public function setAuthorizationCode($code, $client_id, $user_id, $redirect_uri, $expires, $scope = null, $id_token = null) { // if it exists, update it. if ($this->getAuthorizationCode($code)) { @@ -182,7 +185,8 @@ class Mongo implements AuthorizationCodeInterface, 'user_id' => $user_id, 'redirect_uri' => $redirect_uri, 'expires' => $expires, - 'scope' => $scope + 'scope' => $scope, + 'id_token' => $id_token, )) ); } else { @@ -193,7 +197,8 @@ class Mongo implements AuthorizationCodeInterface, 'user_id' => $user_id, 'redirect_uri' => $redirect_uri, 'expires' => $expires, - 'scope' => $scope + 'scope' => $scope, + 'id_token' => $id_token, ) ); } diff --git a/src/OAuth2/Storage/Pdo.php b/src/OAuth2/Storage/Pdo.php index 6f0eac3..0cdd52e 100644 --- a/src/OAuth2/Storage/Pdo.php +++ b/src/OAuth2/Storage/Pdo.php @@ -2,6 +2,8 @@ namespace OAuth2\Storage; +use OAuth2\OpenID\Storage\UserClaimsInterface; +use OAuth2\OpenID\Storage\AuthorizationCodeInterface as OpenIDAuthorizationCodeInterface; /** * Simple PDO storage for all storage types * @@ -21,7 +23,9 @@ class Pdo implements AuthorizationCodeInterface, RefreshTokenInterface, JwtBearerInterface, ScopeInterface, - PublicKeyInterface + PublicKeyInterface, + UserClaimsInterface, + OpenIDAuthorizationCodeInterface { protected $db; protected $config; @@ -29,8 +33,11 @@ class Pdo implements AuthorizationCodeInterface, public function __construct($connection, $config = array()) { if (!$connection instanceof \PDO) { + if (is_string($connection)) { + $connection = array('dsn' => $connection); + } if (!is_array($connection)) { - throw new \InvalidArgumentException('First argument to OAuth2\Storage\Pdo must be an instance of PDO or a configuration array'); + throw new \InvalidArgumentException('First argument to OAuth2\Storage\Pdo must be an instance of PDO, a DSN string, or a configuration array'); } if (!isset($connection['dsn'])) { throw new \InvalidArgumentException('configuration array must contain "dsn"'); @@ -39,8 +46,9 @@ class Pdo implements AuthorizationCodeInterface, $connection = array_merge(array( 'username' => null, 'password' => null, + 'options' => array(), ), $connection); - $connection = new \PDO($connection['dsn'], $connection['username'], $connection['password']); + $connection = new \PDO($connection['dsn'], $connection['username'], $connection['password'], $connection['options']); } $this->db = $connection; @@ -159,8 +167,13 @@ class Pdo implements AuthorizationCodeInterface, return $code; } - public function setAuthorizationCode($code, $client_id, $user_id, $redirect_uri, $expires, $scope = null) + public function setAuthorizationCode($code, $client_id, $user_id, $redirect_uri, $expires, $scope = null, $id_token = null) { + if (func_num_args() > 6) { + // we are calling with an id token + return call_user_func_array(array($this, 'setAuthorizationCodeWithIdToken'), func_get_args()); + } + // convert expires to datestring $expires = date('Y-m-d H:i:s', $expires); @@ -174,6 +187,21 @@ class Pdo implements AuthorizationCodeInterface, return $stmt->execute(compact('code', 'client_id', 'user_id', 'redirect_uri', 'expires', 'scope')); } + private function setAuthorizationCodeWithIdToken($code, $client_id, $user_id, $redirect_uri, $expires, $scope = null, $id_token = null) + { + // convert expires to datestring + $expires = date('Y-m-d H:i:s', $expires); + + // if it exists, update it. + if ($this->getAuthorizationCode($code)) { + $stmt = $this->db->prepare($sql = sprintf('UPDATE %s SET client_id=:client_id, user_id=:user_id, redirect_uri=:redirect_uri, expires=:expires, scope=:scope, id_token =:id_token where authorization_code=:code', $this->config['code_table'])); + } else { + $stmt = $this->db->prepare(sprintf('INSERT INTO %s (authorization_code, client_id, user_id, redirect_uri, expires, scope, id_token) VALUES (:code, :client_id, :user_id, :redirect_uri, :expires, :scope, :id_token)', $this->config['code_table'])); + } + + return $stmt->execute(compact('code', 'client_id', 'user_id', 'redirect_uri', 'expires', 'scope', 'id_token')); + } + public function expireAuthorizationCode($code) { $stmt = $this->db->prepare(sprintf('DELETE FROM %s WHERE authorization_code = :code', $this->config['code_table'])); @@ -196,6 +224,45 @@ class Pdo implements AuthorizationCodeInterface, return $this->getUser($username); } + /* UserClaimsInterface */ + public function getUserClaims($user_id, $claims) + { + if (!$userDetails = $this->getUserDetails($user_id)) { + return false; + } + + $claims = explode(' ', trim($claims)); + $userClaims = array(); + + // for each requested claim, if the user has the claim, set it in the response + $validClaims = explode(' ', self::VALID_CLAIMS); + foreach ($validClaims as $validClaim) { + if (in_array($validClaim, $claims)) { + if ($validClaim == 'address') { + // address is an object with subfields + $userClaims['address'] = $this->getUserClaim($validClaim, $userDetails['address'] ?: $userDetails); + } else { + $userClaims = array_merge($this->getUserClaim($validClaim, $userDetails)); + } + } + } + + return $userClaims; + } + + protected function getUserClaim($claim, $userDetails) + { + $userClaims = array(); + $claimValuesString = constant(sprintf('self::%s_CLAIM_VALUES', strtoupper($claim))); + $claimValues = explode(' ', $claimValuesString); + + foreach ($claimValues as $value) { + $userClaims[$value] = isset($userDetails[$value]) ? $userDetails[$value] : null; + } + + return $userClaims; + } + /* OAuth2\Storage\RefreshTokenInterface */ public function getRefreshToken($refresh_token) { @@ -267,8 +334,9 @@ class Pdo implements AuthorizationCodeInterface, public function scopeExists($scope) { $scope = explode(' ', $scope); - $stmt = $this->db->prepare(sprintf('SELECT count(scope) as count FROM %s WHERE scope IN ("%s")', $this->config['scope_table'], implode('","', $scope))); - $stmt->execute(); + $whereIn = implode(',', array_fill(0, count($scope), '?')); + $stmt = $this->db->prepare(sprintf('SELECT count(scope) as count FROM %s WHERE scope IN (%s)', $this->config['scope_table'], $whereIn)); + $stmt->execute($scope); if ($result = $stmt->fetch()) { return $result['count'] == count($scope); @@ -318,7 +386,7 @@ class Pdo implements AuthorizationCodeInterface, public function getJti($client_id, $subject, $audience, $expires, $jti) { - $stmt = $this->db->prepare($sql = sprintf('SELECT* FROM %s WHERE issuer=:client_id AND subject=:subject AND audience=:audience AND expires=:expires AND jti=:jti', $this->config['jti_table'])); + $stmt = $this->db->prepare($sql = sprintf('SELECT * FROM %s WHERE issuer=:client_id AND subject=:subject AND audience=:audience AND expires=:expires AND jti=:jti', $this->config['jti_table'])); $stmt->execute(compact('client_id', 'subject', 'audience', 'expires', 'jti')); diff --git a/src/OAuth2/Storage/Redis.php b/src/OAuth2/Storage/Redis.php index ee6a4ca..fd94f47 100644 --- a/src/OAuth2/Storage/Redis.php +++ b/src/OAuth2/Storage/Redis.php @@ -2,6 +2,8 @@ namespace OAuth2\Storage; +use OAuth2\OpenID\Storage\AuthorizationCodeInterface as OpenIDAuthorizationCodeInterface; + /** * redis storage for all storage types * @@ -19,7 +21,8 @@ class Redis implements AuthorizationCodeInterface, UserCredentialsInterface, RefreshTokenInterface, JwtBearerInterface, - ScopeInterface + ScopeInterface, + OpenIDAuthorizationCodeInterface { private $cache; @@ -92,11 +95,11 @@ class Redis implements AuthorizationCodeInterface, return $this->getValue($this->config['code_key'] . $code); } - public function setAuthorizationCode($authorization_code, $client_id, $user_id, $redirect_uri, $expires, $scope = null) + public function setAuthorizationCode($authorization_code, $client_id, $user_id, $redirect_uri, $expires, $scope = null, $id_token = null) { return $this->setValue( $this->config['code_key'] . $authorization_code, - compact('authorization_code', 'client_id', 'user_id', 'redirect_uri', 'expires', 'scope'), + compact('authorization_code', 'client_id', 'user_id', 'redirect_uri', 'expires', 'scope', 'id_token'), $expires ); } diff --git a/src/OAuth2/TokenType/Bearer.php b/src/OAuth2/TokenType/Bearer.php index f437a0b..0672572 100644 --- a/src/OAuth2/TokenType/Bearer.php +++ b/src/OAuth2/TokenType/Bearer.php @@ -26,6 +26,19 @@ class Bearer implements TokenTypeInterface } /** + * Check if the request has supplied token + * + * @see https://github.com/bshaffer/oauth2-server-php/issues/349#issuecomment-37993588 + */ + public function requestHasToken(RequestInterface $request) + { + $headers = $request->headers('AUTHORIZATION'); + + // check the header, then the querystring, then the request body + return !empty($headers) || (bool)($request->request($this->config['token_param_name'])) || (bool)($request->query($this->config['token_param_name'])); + } + + /** * This is a convenience function that can be used to get the token, which can then * be passed to getAccessTokenData(). The constraints specified by the draft are * attempted to be adheared to in this method. @@ -88,9 +101,9 @@ class Bearer implements TokenTypeInterface } if ($request->request($this->config['token_param_name'])) { - // POST: Get the token from POST data - if (strtolower($request->server('REQUEST_METHOD')) != 'post') { - $response->setError(400, 'invalid_request', 'When putting the token in the body, the method must be POST'); + // // POST: Get the token from POST data + if (!in_array(strtolower($request->server('REQUEST_METHOD')), array('post', 'put'))) { + $response->setError(400, 'invalid_request', 'When putting the token in the body, the method must be POST or PUT', '#section-2.2'); return null; } |
