diff options
54 files changed, 2039 insertions, 123 deletions
@@ -1,3 +1,5 @@ # Test Files # test/config/test.sqlite vendor +composer.lock +.idea diff --git a/.travis.yml b/.travis.yml index d68d047..cfa65e9 100644 --- a/.travis.yml +++ b/.travis.yml @@ -4,6 +4,8 @@ php: - 5.3 - 5.4 - 5.5 + - 5.6 + - hhvm env: - SKIP_MONGO_TESTS=1 @@ -14,11 +16,6 @@ services: - cassandra before_script: - - echo "extension = mongo.so" >> ~/.phpenv/versions/$(phpenv version-name)/etc/php.ini + - psql -c 'create database oauth2_server_php;' -U postgres - composer require predis/predis:dev-master - composer require thobbs/phpcassa:dev-master - -after_failure: - - ps ax | grep mongo - - cat /etc/mongodb.conf - - cat /var/log/mongodb/mongodb.log diff --git a/CHANGELOG.md b/CHANGELOG.md index 5d92834..00813f1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,8 +4,41 @@ CHANGELOG for 1.x This changelog references the relevant changes (bug and security fixes) done in 1.x minor versions. -To get the diff for a specific change, go to https://github.com/bshaffer/oauth2-server-php/commit/XXX where XXX is the change hash +To see the files changed for a given bug, go to https://github.com/bshaffer/oauth2-server-php/issues/### where ### is the bug number To get the diff between two versions, go to https://github.com/bshaffer/oauth2-server-php/compare/v1.0...v1.1 +To get the diff for a specific change, go to https://github.com/bshaffer/oauth2-server-php/commit/XXX where XXX is the change hash + +* 1.4 (2014-06-12) + + PR: https://github.com/bshaffer/oauth2-server-php/pull/392 + + * bug #189 Storage\PDO - allows DSN string in constructor + * bug #233 Bearer Tokens - allows token in request body for PUT requests + * bug #346 Fixes open_basedir warning + * bug #351 Adds OpenID Connect support + * bug #355 Adds php 5.6 and HHVM to travis.ci testing + * [BC] bug #358 Adds `getQuerystringIdentifier()` to the GrantType interface + * bug #363 Encryption\JWT - Allows for subclassing JWT Headers + * bug #349 Bearer Tokens - adds requestHasToken method for when access tokens are optional + * bug #301 Encryption\JWT - fixes urlSafeB64Encode(): ensures newlines are replaced as expected + * bug #323 ResourceController - client_id is no longer required to be returned when calling getAccessToken + * bug #367 Storage\PDO - adds Postgres support + * bug #368 Access Tokens - use mcrypt_create_iv or openssl_random_pseudo_bytes to create token string + * bug #376 Request - allows case insensitive headers + * bug #384 Storage\PDO - can pass in PDO options in constructor of PDO storage + * misc fixes #361, #292, #373, #374, #379, #396 +* 1.3 (2014-02-27) + + PR: https://github.com/bshaffer/oauth2-server-php/pull/325 + + * bug #311 adds cassandra storage + * bug #298 fixes response code for user credentials grant type + * bug #318 adds 'use_crypto_tokens' config to Server class for better DX + * [BC] bug #320 pass client_id to getDefaultScope + * bug #324 better feedback when running tests + * bug #335 adds support for non-expiring refresh tokens + * bug #333 fixes Pdo storage for getClientKey + * bug #336 fixes Redis storage for expireAuthorizationCode * 1.3 (2014-02-27) @@ -1,6 +1,6 @@ The MIT License -Copyright (c) 2010 Brent Shaffer +Copyright (c) 2014 Brent Shaffer Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal 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; } diff --git a/test/OAuth2/Controller/AuthorizeControllerTest.php b/test/OAuth2/Controller/AuthorizeControllerTest.php index 08e1d2f..1bce0ef 100644 --- a/test/OAuth2/Controller/AuthorizeControllerTest.php +++ b/test/OAuth2/Controller/AuthorizeControllerTest.php @@ -354,6 +354,11 @@ class AuthorizeControllerTest extends \PHPUnit_Framework_TestCase $this->assertNotNull($query); $this->assertArrayHasKey('code', $query); + // ensure no id_token was saved, since the openid scope wasn't requested + $storage = $server->getStorage('authorization_code'); + $code = $storage->getAuthorizationCode($query['code']); + $this->assertTrue(empty($code['id_token'])); + // ensure no error was returned $this->assertFalse(isset($query['error'])); $this->assertFalse(isset($query['error_description'])); @@ -411,6 +416,47 @@ class AuthorizeControllerTest extends \PHPUnit_Framework_TestCase $this->assertEquals($query['state'], 'test'); } + public function testSuccessfulOpenidConnectRequest() + { + $server = $this->getTestServer(array( + 'use_openid_connect' => true, + 'issuer' => 'bojanz', + )); + + $request = new Request(array( + 'client_id' => 'Test Client ID', + 'redirect_uri' => 'http://adobe.com', + 'response_type' => 'code', + 'state' => 'xyz', + 'scope' => 'openid', + )); + $server->handleAuthorizeRequest($request, $response = new Response(), true); + + $this->assertEquals($response->getStatusCode(), 302); + $location = $response->getHttpHeader('Location'); + $parts = parse_url($location); + parse_str($parts['query'], $query); + + $location = $response->getHttpHeader('Location'); + $parts = parse_url($location); + $this->assertArrayHasKey('query', $parts); + $this->assertFalse(isset($parts['fragment'])); + + // assert fragment is in "application/x-www-form-urlencoded" format + parse_str($parts['query'], $query); + $this->assertNotNull($query); + $this->assertArrayHasKey('code', $query); + + // ensure no error was returned + $this->assertFalse(isset($query['error'])); + $this->assertFalse(isset($query['error_description'])); + + // confirm that the id_token has been created. + $storage = $server->getStorage('authorization_code'); + $code = $storage->getAuthorizationCode($query['code']); + $this->assertTrue(!empty($code['id_token'])); + } + public function testCreateController() { $storage = Bootstrap::getInstance()->getMemoryStorage(); diff --git a/test/OAuth2/Controller/ResourceControllerTest.php b/test/OAuth2/Controller/ResourceControllerTest.php index 962737f..fb25550 100644 --- a/test/OAuth2/Controller/ResourceControllerTest.php +++ b/test/OAuth2/Controller/ResourceControllerTest.php @@ -60,7 +60,7 @@ class ResourceControllerTest extends \PHPUnit_Framework_TestCase $this->assertEquals($response->getStatusCode(), 400); $this->assertEquals($response->getParameter('error'), 'invalid_request'); - $this->assertEquals($response->getParameter('error_description'), 'When putting the token in the body, the method must be POST'); + $this->assertEquals($response->getParameter('error_description'), 'When putting the token in the body, the method must be POST or PUT'); } public function testInvalidContentType() @@ -133,7 +133,7 @@ class ResourceControllerTest extends \PHPUnit_Framework_TestCase $this->assertEquals($response->getStatusCode(), 401); $this->assertEquals($response->getParameter('error'), 'invalid_token'); - $this->assertEquals($response->getParameter('error_description'), 'Malformed token (missing "expires" or "client_id")'); + $this->assertEquals($response->getParameter('error_description'), 'Malformed token (missing "expires")'); } public function testValidToken() diff --git a/test/OAuth2/GrantType/JWTBearerTest.php b/test/OAuth2/GrantType/JwtBearerTest.php index 1ac50f5..0a6c4b8 100644 --- a/test/OAuth2/GrantType/JWTBearerTest.php +++ b/test/OAuth2/GrantType/JwtBearerTest.php @@ -353,7 +353,7 @@ EOD; { $storage = Bootstrap::getInstance()->getMemoryStorage(); $server = new Server($storage); - $server->addGrantType(new JwtBearer($storage, $audience)); + $server->addGrantType(new JwtBearer($storage, $audience, new Jwt())); return $server; } diff --git a/test/OAuth2/OpenID/Controller/UserInfoControllerTest.php b/test/OAuth2/OpenID/Controller/UserInfoControllerTest.php new file mode 100644 index 0000000..939b715 --- /dev/null +++ b/test/OAuth2/OpenID/Controller/UserInfoControllerTest.php @@ -0,0 +1,33 @@ +<?php + +namespace OAuth2\OpenID\Controller; + +use OAuth2\Storage\Bootstrap; +use OAuth2\Server; +use OAuth2\GrantType\AuthorizationCode; +use OAuth2\Request; +use OAuth2\Response; + +class UserInfoControllerTest extends \PHPUnit_Framework_TestCase +{ + public function testValidToken() + { + $server = $this->getTestServer(); + $request = Request::createFromGlobals(); + $request->headers['AUTHORIZATION'] = 'Bearer accesstoken-openid-connect'; + $response = new Response(); + + $server->handleUserInfoRequest($request, $response); + $parameters = $response->getParameters(); + $this->assertEquals($parameters['sub'], 'testuser'); + $this->assertEquals($parameters['email'], 'testuser@test.com'); + $this->assertEquals($parameters['email_verified'], true); + } + + private function getTestServer($config = array()) + { + $storage = Bootstrap::getInstance()->getMemoryStorage(); + $server = new Server($storage, $config); + return $server; + } +} diff --git a/test/OAuth2/OpenID/GrantType/AuthorizationCodeTest.php b/test/OAuth2/OpenID/GrantType/AuthorizationCodeTest.php new file mode 100644 index 0000000..776002d --- /dev/null +++ b/test/OAuth2/OpenID/GrantType/AuthorizationCodeTest.php @@ -0,0 +1,57 @@ +<?php + +namespace OAuth2\OpenID\GrantType; + +use OAuth2\Storage\Bootstrap; +use OAuth2\Server; +use OAuth2\Request\TestRequest; +use OAuth2\Response; + +class AuthorizationCodeTest extends \PHPUnit_Framework_TestCase +{ + public function testValidCode() + { + $server = $this->getTestServer(); + $request = TestRequest::createPost(array( + 'grant_type' => 'authorization_code', // valid grant type + 'client_id' => 'Test Client ID', // valid client id + 'client_secret' => 'TestSecret', // valid client secret + 'code' => 'testcode-openid', // valid code + )); + $token = $server->grantAccessToken($request, new Response()); + + $this->assertNotNull($token); + $this->assertArrayHasKey('id_token', $token); + $this->assertEquals('test_id_token', $token['id_token']); + + // this is only true if "offline_access" was requested + $this->assertFalse(isset($token['refresh_token'])); + } + + public function testOfflineAccess() + { + $server = $this->getTestServer(); + $request = TestRequest::createPost(array( + 'grant_type' => 'authorization_code', // valid grant type + 'client_id' => 'Test Client ID', // valid client id + 'client_secret' => 'TestSecret', // valid client secret + 'code' => 'testcode-openid', // valid code + 'scope' => 'offline_access', // valid code + )); + $token = $server->grantAccessToken($request, new Response()); + + $this->assertNotNull($token); + $this->assertArrayHasKey('id_token', $token); + $this->assertEquals('test_id_token', $token['id_token']); + $this->assertTrue(isset($token['refresh_token'])); + } + + private function getTestServer() + { + $storage = Bootstrap::getInstance()->getMemoryStorage(); + $server = new Server($storage, array('use_openid_connect' => true)); + $server->addGrantType(new AuthorizationCode($storage)); + + return $server; + } +} diff --git a/test/OAuth2/OpenID/ResponseType/IdTokenTest.php b/test/OAuth2/OpenID/ResponseType/IdTokenTest.php new file mode 100644 index 0000000..e772f6b --- /dev/null +++ b/test/OAuth2/OpenID/ResponseType/IdTokenTest.php @@ -0,0 +1,184 @@ +<?php + +namespace OAuth2\OpenID\ResponseType; + +use OAuth2\Server; +use OAuth2\Request; +use OAuth2\Response; +use OAuth2\Storage\Bootstrap; +use OAuth2\GrantType\ClientCredentials; +use OAuth2\Encryption\Jwt; + +class IdTokenTest extends \PHPUnit_Framework_TestCase +{ + public function testValidateAuthorizeRequest() + { + $query = array( + 'response_type' => 'id_token', + 'redirect_uri' => 'http://adobe.com', + 'client_id' => 'Test Client ID', + 'scope' => 'openid', + 'state' => 'test', + ); + + // attempt to do the request without a nonce. + $server = $this->getTestServer(array('allow_implicit' => true)); + $request = new Request($query); + $valid = $server->validateAuthorizeRequest($request, $response = new Response()); + + // Add a nonce and retry. + $query['nonce'] = 'test'; + $request = new Request($query); + $valid = $server->validateAuthorizeRequest($request, $response = new Response()); + $this->assertTrue($valid); + } + + public function testHandleAuthorizeRequest() + { + // add the test parameters in memory + $server = $this->getTestServer(array('allow_implicit' => true)); + $request = new Request(array( + 'response_type' => 'id_token', + 'redirect_uri' => 'http://adobe.com', + 'client_id' => 'Test Client ID', + 'scope' => 'openid email', + 'state' => 'test', + 'nonce' => 'test', + )); + + $user_id = 'testuser'; + $server->handleAuthorizeRequest($request, $response = new Response(), true, $user_id); + + $this->assertEquals($response->getStatusCode(), 302); + $location = $response->getHttpHeader('Location'); + $this->assertNotContains('error', $location); + + $parts = parse_url($location); + $this->assertArrayHasKey('fragment', $parts); + $this->assertFalse(isset($parts['query'])); + + // assert fragment is in "application/x-www-form-urlencoded" format + parse_str($parts['fragment'], $params); + $this->assertNotNull($params); + $this->assertArrayHasKey('id_token', $params); + $this->assertArrayNotHasKey('access_token', $params); + $this->validateIdToken($params['id_token']); + } + + public function testPassInAuthTime() + { + $server = $this->getTestServer(array('allow_implicit' => true)); + $request = new Request(array( + 'response_type' => 'id_token', + 'redirect_uri' => 'http://adobe.com', + 'client_id' => 'Test Client ID', + 'scope' => 'openid email', + 'state' => 'test', + 'nonce' => 'test', + )); + + // test with a scalar user id + $user_id = 'testuser123'; + $server->handleAuthorizeRequest($request, $response = new Response(), true, $user_id); + + list($header, $payload, $signature) = $this->extractTokenDataFromResponse($response); + + $this->assertTrue(is_array($payload)); + $this->assertArrayHasKey('sub', $payload); + $this->assertEquals($user_id, $payload['sub']); + $this->assertArrayHasKey('auth_time', $payload); + + // test with an array of user info + $userInfo = array( + 'user_id' => 'testuser1234', + 'auth_time' => date('Y-m-d H:i:s', strtotime('20 minutes ago') + )); + + $server->handleAuthorizeRequest($request, $response = new Response(), true, $userInfo); + + list($header, $payload, $signature) = $this->extractTokenDataFromResponse($response); + + $this->assertTrue(is_array($payload)); + $this->assertArrayHasKey('sub', $payload); + $this->assertEquals($userInfo['user_id'], $payload['sub']); + $this->assertArrayHasKey('auth_time', $payload); + $this->assertEquals($userInfo['auth_time'], $payload['auth_time']); + } + + private function extractTokenDataFromResponse(Response $response) + { + $this->assertEquals($response->getStatusCode(), 302); + $location = $response->getHttpHeader('Location'); + $this->assertNotContains('error', $location); + + $parts = parse_url($location); + $this->assertArrayHasKey('fragment', $parts); + $this->assertFalse(isset($parts['query'])); + + parse_str($parts['fragment'], $params); + $this->assertNotNull($params); + $this->assertArrayHasKey('id_token', $params); + $this->assertArrayNotHasKey('access_token', $params); + + list($headb64, $payloadb64, $signature) = explode('.', $params['id_token']); + + $jwt = new Jwt(); + $header = json_decode($jwt->urlSafeB64Decode($headb64), true); + $payload = json_decode($jwt->urlSafeB64Decode($payloadb64), true); + + return array($header, $payload, $signature); + } + + private function validateIdToken($id_token) + { + $parts = explode('.', $id_token); + foreach ($parts as &$part) { + // Each part is a base64url encoded json string. + $part = str_replace(array('-', '_'), array('+', '/'), $part); + $part = base64_decode($part); + $part = json_decode($part, true); + } + list($header, $claims, $signature) = $parts; + + $this->assertArrayHasKey('iss', $claims); + $this->assertArrayHasKey('sub', $claims); + $this->assertArrayHasKey('aud', $claims); + $this->assertArrayHasKey('iat', $claims); + $this->assertArrayHasKey('exp', $claims); + $this->assertArrayHasKey('auth_time', $claims); + $this->assertArrayHasKey('nonce', $claims); + $this->assertArrayHasKey('email', $claims); + $this->assertArrayHasKey('email_verified', $claims); + + $this->assertEquals($claims['iss'], 'test'); + $this->assertEquals($claims['aud'], 'Test Client ID'); + $this->assertEquals($claims['nonce'], 'test'); + $this->assertEquals($claims['email'], 'testuser@test.com'); + $duration = $claims['exp'] - $claims['iat']; + $this->assertEquals($duration, 3600); + } + + private function getTestServer($config = array()) + { + $config += array( + 'use_openid_connect' => true, + 'issuer' => 'test', + 'id_lifetime' => 3600, + ); + + $memoryStorage = Bootstrap::getInstance()->getMemoryStorage(); + $memoryStorage->supportedScopes[] = 'email'; + $storage = array( + 'client' => $memoryStorage, + 'scope' => $memoryStorage, + ); + $responseTypes = array( + 'id_token' => new IdToken($memoryStorage, $memoryStorage, $config), + ); + + $server = new Server($storage, $config, array(), $responseTypes); + $server->addGrantType(new ClientCredentials($memoryStorage)); + + return $server; + } +} diff --git a/test/OAuth2/OpenID/ResponseType/TokenIdTokenTest.php b/test/OAuth2/OpenID/ResponseType/TokenIdTokenTest.php new file mode 100644 index 0000000..488eb08 --- /dev/null +++ b/test/OAuth2/OpenID/ResponseType/TokenIdTokenTest.php @@ -0,0 +1,93 @@ +<?php + +namespace OAuth2\OpenID\ResponseType; + +use OAuth2\Server; +use OAuth2\Request; +use OAuth2\Response; +use OAuth2\Storage\Bootstrap; +use OAuth2\GrantType\ClientCredentials; +use OAuth2\ResponseType\AccessToken; + +class TokenIdTokenTest extends \PHPUnit_Framework_TestCase +{ + + public function testHandleAuthorizeRequest() + { + // add the test parameters in memory + $server = $this->getTestServer(array('allow_implicit' => true)); + $request = new Request(array( + 'response_type' => 'token id_token', + 'redirect_uri' => 'http://adobe.com', + 'client_id' => 'Test Client ID', + 'scope' => 'openid', + 'state' => 'test', + 'nonce' => 'test', + )); + + $server->handleAuthorizeRequest($request, $response = new Response(), true); + + $this->assertEquals($response->getStatusCode(), 302); + $location = $response->getHttpHeader('Location'); + $this->assertNotContains('error', $location); + + $parts = parse_url($location); + $this->assertArrayHasKey('fragment', $parts); + $this->assertFalse(isset($parts['query'])); + + // assert fragment is in "application/x-www-form-urlencoded" format + parse_str($parts['fragment'], $params); + $this->assertNotNull($params); + $this->assertArrayHasKey('id_token', $params); + $this->assertArrayHasKey('access_token', $params); + $this->validateIdToken($params['id_token']); + } + + private function validateIdToken($id_token) + { + $parts = explode('.', $id_token); + foreach ($parts as &$part) { + // Each part is a base64url encoded json string. + $part = str_replace(array('-', '_'), array('+', '/'), $part); + $part = base64_decode($part); + $part = json_decode($part, TRUE); + } + list($header, $claims, $signature) = $parts; + + $this->assertArrayHasKey('iss', $claims); + $this->assertArrayHasKey('sub', $claims); + $this->assertArrayHasKey('aud', $claims); + $this->assertArrayHasKey('iat', $claims); + $this->assertArrayHasKey('exp', $claims); + $this->assertArrayHasKey('auth_time', $claims); + $this->assertArrayHasKey('nonce', $claims); + $this->assertArrayHasKey('at_hash', $claims); + + $this->assertEquals($claims['iss'], 'test'); + $this->assertEquals($claims['aud'], 'Test Client ID'); + $this->assertEquals($claims['nonce'], 'test'); + $duration = $claims['exp'] - $claims['iat']; + $this->assertEquals($duration, 3600); + } + + private function getTestServer($config = array()) + { + $config += array( + 'use_openid_connect' => true, + 'issuer' => 'test', + 'id_lifetime' => 3600, + ); + + $memoryStorage = Bootstrap::getInstance()->getMemoryStorage(); + $responseTypes = array( + 'token' => new AccessToken($memoryStorage, $memoryStorage), + 'id_token' => new IdToken($memoryStorage, $memoryStorage, $config), + ); + $responseTypes['token id_token'] = new TokenIdToken($responseTypes['token'], $responseTypes['id_token']); + + $server = new Server($memoryStorage, $config, array(), $responseTypes); + $server->addGrantType(new ClientCredentials($memoryStorage)); + + return $server; + } +} diff --git a/test/OAuth2/OpenID/Storage/AuthorizationCodeTest.php b/test/OAuth2/OpenID/Storage/AuthorizationCodeTest.php new file mode 100644 index 0000000..bdfb085 --- /dev/null +++ b/test/OAuth2/OpenID/Storage/AuthorizationCodeTest.php @@ -0,0 +1,95 @@ +<?php + +namespace OAuth2\OpenID\Storage; + +use OAuth2\Storage\BaseTest; +use OAuth2\Storage\NullStorage; + +class AuthorizationCodeTest extends BaseTest +{ + /** @dataProvider provideStorage */ + public function testCreateAuthorizationCode($storage) + { + if ($storage instanceof NullStorage) { + $this->markTestSkipped('Skipped Storage: ' . $storage->getMessage()); + + return; + } + + if (!$storage instanceof AuthorizationCodeInterface) { + return; + } + + // assert code we are about to add does not exist + $code = $storage->getAuthorizationCode('new-openid-code'); + $this->assertFalse($code); + + // add new code + $expires = time() + 20; + $scope = null; + $id_token = 'fake_id_token'; + $success = $storage->setAuthorizationCode('new-openid-code', 'client ID', 'SOMEUSERID', 'http://example.com', $expires, $scope, $id_token); + $this->assertTrue($success); + + $code = $storage->getAuthorizationCode('new-openid-code'); + $this->assertNotNull($code); + $this->assertArrayHasKey('authorization_code', $code); + $this->assertArrayHasKey('client_id', $code); + $this->assertArrayHasKey('user_id', $code); + $this->assertArrayHasKey('redirect_uri', $code); + $this->assertArrayHasKey('expires', $code); + $this->assertEquals($code['authorization_code'], 'new-openid-code'); + $this->assertEquals($code['client_id'], 'client ID'); + $this->assertEquals($code['user_id'], 'SOMEUSERID'); + $this->assertEquals($code['redirect_uri'], 'http://example.com'); + $this->assertEquals($code['expires'], $expires); + $this->assertEquals($code['id_token'], $id_token); + + // change existing code + $expires = time() + 42; + $new_id_token = 'fake_id_token-2'; + $success = $storage->setAuthorizationCode('new-openid-code', 'client ID2', 'SOMEOTHERID', 'http://example.org', $expires, $scope, $new_id_token); + $this->assertTrue($success); + + $code = $storage->getAuthorizationCode('new-openid-code'); + $this->assertNotNull($code); + $this->assertArrayHasKey('authorization_code', $code); + $this->assertArrayHasKey('client_id', $code); + $this->assertArrayHasKey('user_id', $code); + $this->assertArrayHasKey('redirect_uri', $code); + $this->assertArrayHasKey('expires', $code); + $this->assertEquals($code['authorization_code'], 'new-openid-code'); + $this->assertEquals($code['client_id'], 'client ID2'); + $this->assertEquals($code['user_id'], 'SOMEOTHERID'); + $this->assertEquals($code['redirect_uri'], 'http://example.org'); + $this->assertEquals($code['expires'], $expires); + $this->assertEquals($code['id_token'], $new_id_token); + } + + /** @dataProvider provideStorage */ + public function testRemoveIdTokenFromAuthorizationCode($storage) + { + // add new code + $expires = time() + 20; + $scope = null; + $id_token = 'fake_id_token_to_remove'; + $authcode = 'new-openid-code-'.rand(); + $success = $storage->setAuthorizationCode($authcode, 'client ID', 'SOMEUSERID', 'http://example.com', $expires, $scope, $id_token); + $this->assertTrue($success); + + // verify params were set + $code = $storage->getAuthorizationCode($authcode); + $this->assertNotNull($code); + $this->assertArrayHasKey('id_token', $code); + $this->assertEquals($code['id_token'], $id_token); + + // remove the id_token + $success = $storage->setAuthorizationCode($authcode, 'client ID', 'SOMEUSERID', 'http://example.com', $expires, $scope, null); + + // verify the "id_token" is now null + $code = $storage->getAuthorizationCode($authcode); + $this->assertNotNull($code); + $this->assertArrayHasKey('id_token', $code); + $this->assertEquals($code['id_token'], null); + } +} diff --git a/test/OAuth2/OpenID/Storage/UserClaimsTest.php b/test/OAuth2/OpenID/Storage/UserClaimsTest.php new file mode 100644 index 0000000..840f6c5 --- /dev/null +++ b/test/OAuth2/OpenID/Storage/UserClaimsTest.php @@ -0,0 +1,41 @@ +<?php + +namespace OAuth2\OpenID\Storage; + +use OAuth2\Storage\BaseTest; +use OAuth2\Storage\NullStorage; + +class UserClaimsTest extends BaseTest +{ + /** @dataProvider provideStorage */ + public function testGetUserClaims($storage) + { + if ($storage instanceof NullStorage) { + $this->markTestSkipped('Skipped Storage: ' . $storage->getMessage()); + + return; + } + + if (!$storage instanceof UserClaimsInterface) { + // incompatible storage + return; + } + + // invalid user + $claims = $storage->getUserClaims('fake-user', ''); + $this->assertFalse($claims); + + // valid user (no scope) + $claims = $storage->getUserClaims('testuser', ''); + + /* assert the decoded token is the same */ + $this->assertFalse(isset($claims['email'])); + + // valid user + $claims = $storage->getUserClaims('testuser', 'email'); + + /* assert the decoded token is the same */ + $this->assertEquals($claims['email'], "testuser@test.com"); + $this->assertEquals($claims['email_verified'], true); + } +} diff --git a/test/OAuth2/RequestTest.php b/test/OAuth2/RequestTest.php index 138c2c2..a7bcc4e 100644 --- a/test/OAuth2/RequestTest.php +++ b/test/OAuth2/RequestTest.php @@ -36,6 +36,45 @@ class RequestTest extends \PHPUnit_Framework_TestCase $this->assertNotNUll($response->getParameter('access_token')); } + public function testHeadersReturnsValueByKey() + { + $request = new Request( + array(), + array(), + array(), + array(), + array(), + array(), + array(), + array('AUTHORIZATION' => 'Basic secret') + ); + + $this->assertEquals('Basic secret', $request->headers('AUTHORIZATION')); + } + + public function testHeadersReturnsDefaultIfHeaderNotPresent() + { + $request = new Request(); + + $this->assertEquals('Bearer', $request->headers('AUTHORIZATION', 'Bearer')); + } + + public function testHeadersIsCaseInsensitive() + { + $request = new Request( + array(), + array(), + array(), + array(), + array(), + array(), + array(), + array('AUTHORIZATION' => 'Basic secret') + ); + + $this->assertEquals('Basic secret', $request->headers('Authorization')); + } + private function getTestServer($config = array()) { $storage = Bootstrap::getInstance()->getMemoryStorage(); diff --git a/test/OAuth2/ServerTest.php b/test/OAuth2/ServerTest.php index d2710ac..d3c8069 100644 --- a/test/OAuth2/ServerTest.php +++ b/test/OAuth2/ServerTest.php @@ -199,6 +199,40 @@ class ServerTest extends \PHPUnit_Framework_TestCase $this->assertEquals($client_credentials, $memory); } + public function testAddStorageWithNullValue() + { + $memory = $this->getMock('OAuth2\Storage\Memory'); + $server = new Server($memory); + $server->addStorage(null, 'refresh_token'); + + $client_credentials = $server->getStorage('client_credentials'); + + $this->assertNotNull($client_credentials); + $this->assertEquals($client_credentials, $memory); + + $refresh_token = $server->getStorage('refresh_token'); + + $this->assertNull($refresh_token); + } + + public function testNewServerWithNullStorageValue() + { + $memory = $this->getMock('OAuth2\Storage\Memory'); + $server = new Server(array( + 'client_credentials' => $memory, + 'refresh_token' => null, + )); + + $client_credentials = $server->getStorage('client_credentials'); + + $this->assertNotNull($client_credentials); + $this->assertEquals($client_credentials, $memory); + + $refresh_token = $server->getStorage('refresh_token'); + + $this->assertNull($refresh_token); + } + public function testAddingClientCredentialsStorageSetsClientStorageByDefault() { $server = new Server(); @@ -405,7 +439,7 @@ class ServerTest extends \PHPUnit_Framework_TestCase $server->getTokenController(); } - public function testUsingCryptoTokenAndClientStorageWithAuthorizeControllerIsOk() + public function testUsingCryptoTokenAndClientStorageWithAuthorizeControllerIsOkay() { $pubkey = $this->getMock('OAuth2\Storage\PublicKeyInterface'); $client = $this->getMock('OAuth2\Storage\ClientInterface'); @@ -414,4 +448,196 @@ class ServerTest extends \PHPUnit_Framework_TestCase $this->assertInstanceOf('OAuth2\ResponseType\CryptoToken', $server->getResponseType('token')); } + + /** + * @expectedException LogicException UserClaims + **/ + public function testUsingOpenIDConnectWithoutUserClaimsThrowsException() + { + $client = $this->getMock('OAuth2\Storage\ClientInterface'); + $server = new Server($client, array('use_openid_connect' => true)); + + $server->getAuthorizeController(); + } + + /** + * @expectedException LogicException PublicKeyInterface + **/ + public function testUsingOpenIDConnectWithoutPublicKeyThrowsException() + { + $client = $this->getMock('OAuth2\Storage\ClientInterface'); + $userclaims = $this->getMock('OAuth2\OPenID\Storage\UserClaimsInterface'); + $server = new Server(array($client, $userclaims), array('use_openid_connect' => true)); + + $server->getAuthorizeController(); + } + + /** + * @expectedException LogicException issuer + **/ + public function testUsingOpenIDConnectWithoutIssuerThrowsException() + { + $client = $this->getMock('OAuth2\Storage\ClientInterface'); + $userclaims = $this->getMock('OAuth2\OpenID\Storage\UserClaimsInterface'); + $pubkey = $this->getMock('OAuth2\Storage\PublicKeyInterface'); + $server = new Server(array($client, $userclaims, $pubkey), array('use_openid_connect' => true)); + + $server->getAuthorizeController(); + } + + public function testUsingOpenIDConnectWithIssuerPublicKeyAndUserClaimsIsOkay() + { + $client = $this->getMock('OAuth2\Storage\ClientInterface'); + $userclaims = $this->getMock('OAuth2\OpenID\Storage\UserClaimsInterface'); + $pubkey = $this->getMock('OAuth2\Storage\PublicKeyInterface'); + $server = new Server(array($client, $userclaims, $pubkey), array( + 'use_openid_connect' => true, + 'issuer' => 'someguy', + )); + + $server->getAuthorizeController(); + + $this->assertInstanceOf('OAuth2\OpenID\ResponseType\IdTokenInterface', $server->getResponseType('id_token')); + $this->assertNull($server->getResponseType('token id_token')); + } + + /** + * @expectedException LogicException OAuth2\ResponseType\AccessTokenInterface + **/ + public function testUsingOpenIDConnectWithAllowImplicitWithoutTokenStorageThrowsException() + { + $client = $this->getMock('OAuth2\Storage\ClientInterface'); + $userclaims = $this->getMock('OAuth2\OpenID\Storage\UserClaimsInterface'); + $pubkey = $this->getMock('OAuth2\Storage\PublicKeyInterface'); + $server = new Server(array($client, $userclaims, $pubkey), array( + 'use_openid_connect' => true, + 'issuer' => 'someguy', + 'allow_implicit' => true, + )); + + $server->getAuthorizeController(); + } + + public function testUsingOpenIDConnectWithAllowImplicitAndUseCryptoTokensIsOkay() + { + $client = $this->getMock('OAuth2\Storage\ClientInterface'); + $userclaims = $this->getMock('OAuth2\OpenID\Storage\UserClaimsInterface'); + $pubkey = $this->getMock('OAuth2\Storage\PublicKeyInterface'); + $server = new Server(array($client, $userclaims, $pubkey), array( + 'use_openid_connect' => true, + 'issuer' => 'someguy', + 'allow_implicit' => true, + 'use_crypto_tokens' => true, + )); + + $server->getAuthorizeController(); + + $this->assertInstanceOf('OAuth2\OpenID\ResponseType\IdTokenInterface', $server->getResponseType('id_token')); + $this->assertInstanceOf('OAuth2\OpenID\ResponseType\TokenIdTokenInterface', $server->getResponseType('token id_token')); + } + + public function testUsingOpenIDConnectWithAllowImplicitAndAccessTokenStorageIsOkay() + { + $client = $this->getMock('OAuth2\Storage\ClientInterface'); + $userclaims = $this->getMock('OAuth2\OpenID\Storage\UserClaimsInterface'); + $pubkey = $this->getMock('OAuth2\Storage\PublicKeyInterface'); + $token = $this->getMock('OAuth2\Storage\AccessTokenInterface'); + $server = new Server(array($client, $userclaims, $pubkey, $token), array( + 'use_openid_connect' => true, + 'issuer' => 'someguy', + 'allow_implicit' => true, + )); + + $server->getAuthorizeController(); + + $this->assertInstanceOf('OAuth2\OpenID\ResponseType\IdTokenInterface', $server->getResponseType('id_token')); + $this->assertInstanceOf('OAuth2\OpenID\ResponseType\TokenIdTokenInterface', $server->getResponseType('token id_token')); + } + + public function testUsingOpenIDConnectWithAllowImplicitAndAccessTokenResponseTypeIsOkay() + { + $client = $this->getMock('OAuth2\Storage\ClientInterface'); + $userclaims = $this->getMock('OAuth2\OpenID\Storage\UserClaimsInterface'); + $pubkey = $this->getMock('OAuth2\Storage\PublicKeyInterface'); + // $token = $this->getMock('OAuth2\Storage\AccessTokenInterface'); + $server = new Server(array($client, $userclaims, $pubkey), array( + 'use_openid_connect' => true, + 'issuer' => 'someguy', + 'allow_implicit' => true, + )); + + $token = $this->getMock('OAuth2\ResponseType\AccessTokenInterface'); + $server->addResponseType($token, 'token'); + + $server->getAuthorizeController(); + + $this->assertInstanceOf('OAuth2\OpenID\ResponseType\IdTokenInterface', $server->getResponseType('id_token')); + $this->assertInstanceOf('OAuth2\OpenID\ResponseType\TokenIdTokenInterface', $server->getResponseType('token id_token')); + } + + /** + * @expectedException LogicException OAuth2\OpenID\Storage\AuthorizationCodeInterface + **/ + public function testUsingOpenIDConnectWithAuthorizationCodeStorageThrowsException() + { + $client = $this->getMock('OAuth2\Storage\ClientCredentialsInterface'); + $userclaims = $this->getMock('OAuth2\OpenID\Storage\UserClaimsInterface'); + $pubkey = $this->getMock('OAuth2\Storage\PublicKeyInterface'); + $token = $this->getMock('OAuth2\Storage\AccessTokenInterface'); + $authcode = $this->getMock('OAuth2\Storage\AuthorizationCodeInterface'); + + $server = new Server(array($client, $userclaims, $pubkey, $token, $authcode), array( + 'use_openid_connect' => true, + 'issuer' => 'someguy' + )); + + $server->getTokenController(); + + $this->assertInstanceOf('OAuth2\OpenID\GrantType\AuthorizationCode', $server->getGrantType('authorization_code')); + } + + public function testUsingOpenIDConnectWithOpenIDAuthorizationCodeStorageCreatesOpenIDAuthorizationCodeGrantType() + { + $client = $this->getMock('OAuth2\Storage\ClientCredentialsInterface'); + $userclaims = $this->getMock('OAuth2\OpenID\Storage\UserClaimsInterface'); + $pubkey = $this->getMock('OAuth2\Storage\PublicKeyInterface'); + $token = $this->getMock('OAuth2\Storage\AccessTokenInterface'); + $authcode = $this->getMock('OAuth2\OpenID\Storage\AuthorizationCodeInterface'); + + $server = new Server(array($client, $userclaims, $pubkey, $token, $authcode), array( + 'use_openid_connect' => true, + 'issuer' => 'someguy' + )); + + $server->getTokenController(); + + $this->assertInstanceOf('OAuth2\OpenID\GrantType\AuthorizationCode', $server->getGrantType('authorization_code')); + } + + public function testAddGrantTypeWithoutKey() + { + $server = new Server(); + $server->addGrantType(new \OAuth2\GrantType\AuthorizationCode($this->getMock('OAuth2\Storage\AuthorizationCodeInterface'))); + + $grantTypes = $server->getGrantTypes(); + $this->assertEquals('authorization_code', key($grantTypes)); + } + + public function testAddGrantTypeWithKey() + { + $server = new Server(); + $server->addGrantType(new \OAuth2\GrantType\AuthorizationCode($this->getMock('OAuth2\Storage\AuthorizationCodeInterface')), 'ac'); + + $grantTypes = $server->getGrantTypes(); + $this->assertEquals('ac', key($grantTypes)); + } + + public function testAddGrantTypeWithKeyNotString() + { + $server = new Server(); + $server->addGrantType(new \OAuth2\GrantType\AuthorizationCode($this->getMock('OAuth2\Storage\AuthorizationCodeInterface')), 42); + + $grantTypes = $server->getGrantTypes(); + $this->assertEquals('authorization_code', key($grantTypes)); + } } diff --git a/test/OAuth2/Storage/PdoTest.php b/test/OAuth2/Storage/PdoTest.php new file mode 100644 index 0000000..f6c0549 --- /dev/null +++ b/test/OAuth2/Storage/PdoTest.php @@ -0,0 +1,41 @@ +<?php + +namespace OAuth2\Storage; + +use OAuth2\Encryption\Jwt; + +class PdoTest extends BaseTest +{ + public function testCreatePdoStorageUsingPdoClass() + { + $pdo = new \PDO(sprintf('sqlite://%s', Bootstrap::getInstance()->getSqliteDir())); + $storage = new Pdo($pdo); + + $this->assertNotNull($storage->getClientDetails('oauth_test_client')); + } + + public function testCreatePdoStorageUsingDSN() + { + $dsn = sprintf('sqlite://%s', Bootstrap::getInstance()->getSqliteDir()); + $storage = new Pdo($dsn); + + $this->assertNotNull($storage->getClientDetails('oauth_test_client')); + } + + public function testCreatePdoStorageUsingConfig() + { + $config = array('dsn' => sprintf('sqlite://%s', Bootstrap::getInstance()->getSqliteDir())); + $storage = new Pdo($config); + + $this->assertNotNull($storage->getClientDetails('oauth_test_client')); + } + + /** + * @expectedException InvalidArgumentException dsn + */ + public function testCreatePdoStorageWithoutDSNThrowsException() + { + $config = array('username' => 'brent', 'password' => 'brentisaballer'); + $storage = new Pdo($config); + } +} diff --git a/test/OAuth2/TokenType/BearerTest.php b/test/OAuth2/TokenType/BearerTest.php index f126823..a2e000e 100644 --- a/test/OAuth2/TokenType/BearerTest.php +++ b/test/OAuth2/TokenType/BearerTest.php @@ -33,4 +33,26 @@ class BearerTest extends \PHPUnit_Framework_TestCase $this->assertEquals($response->getParameter('error'), 'invalid_request'); $this->assertEquals($response->getParameter('error_description'), 'The content type for POST requests must be "application/x-www-form-urlencoded"'); } + + public function testValidRequestUsingAuthorizationHeader() + { + $bearer = new Bearer(); + $request = new TestRequest(); + $request->headers['AUTHORIZATION'] = 'Bearer MyToken'; + $request->server['CONTENT_TYPE'] = 'application/x-www-form-urlencoded; charset=UTF-8'; + + $param = $bearer->getAccessTokenParameter($request, $response = new Response()); + $this->assertEquals('MyToken', $param); + } + + public function testValidRequestUsingAuthorizationHeaderCaseInsensitive() + { + $bearer = new Bearer(); + $request = new TestRequest(); + $request->server['CONTENT_TYPE'] = 'application/x-www-form-urlencoded; charset=UTF-8'; + $request->headers['Authorization'] = 'Bearer MyToken'; + + $param = $bearer->getAccessTokenParameter($request, $response = new Response()); + $this->assertEquals('MyToken', $param); + } } diff --git a/test/config/storage.json b/test/config/storage.json index a5470fb..82a7924 100644 --- a/test/config/storage.json +++ b/test/config/storage.json @@ -4,7 +4,8 @@ "client_id": "Test Client ID", "user_id": "", "redirect_uri": "", - "expires": "9999999999" + "expires": "9999999999", + "id_token": "IDTOKEN" }, "testcode-with-scope": { "client_id": "Test Client ID", @@ -24,6 +25,13 @@ "user_id": "", "redirect_uri": "", "expires": "9999999999" + }, + "testcode-openid": { + "client_id": "Test Client ID", + "user_id": "", + "redirect_uri": "", + "expires": "9999999999", + "id_token": "test_id_token" } }, "client_credentials" : { @@ -68,6 +76,11 @@ "testusername": { "password": "testpass" }, + "testuser": { + "password": "password", + "email": "testuser@test.com", + "email_verified": true + }, "johndoe": { "password": "password" } @@ -101,6 +114,13 @@ "expires": 99999999900, "scope": "testscope" }, + "accesstoken-openid-connect": { + "access_token": "accesstoken-openid-connect", + "client_id": "Test Client ID", + "user_id": "testuser", + "expires": 99999999900, + "scope": "openid email" + }, "accesstoken-malformed": { "access_token": "accesstoken-mallformed", "expires": 99999999900, diff --git a/test/lib/OAuth2/Request/TestRequest.php b/test/lib/OAuth2/Request/TestRequest.php index 5e0d081..7bbce28 100644 --- a/test/lib/OAuth2/Request/TestRequest.php +++ b/test/lib/OAuth2/Request/TestRequest.php @@ -2,12 +2,13 @@ namespace OAuth2\Request; +use OAuth2\Request; use OAuth2\RequestInterface; /** * */ -class TestRequest implements RequestInterface +class TestRequest extends Request implements RequestInterface { public $query, $request, $server, $headers; @@ -16,6 +17,7 @@ class TestRequest implements RequestInterface $this->query = $_GET; $this->request = $_POST; $this->server = $_SERVER; + $this->headers = array(); } public function query($name, $default = null) @@ -33,11 +35,6 @@ class TestRequest implements RequestInterface return isset($this->server[$name]) ? $this->server[$name] : $default; } - public function headers($name, $default = null) - { - return isset($this->headers[$name]) ? $this->headers[$name] : $default; - } - public function getAllQueryParameters() { return $this->query; diff --git a/test/lib/OAuth2/Storage/BaseTest.php b/test/lib/OAuth2/Storage/BaseTest.php index 90919b8..f7de3c7 100644 --- a/test/lib/OAuth2/Storage/BaseTest.php +++ b/test/lib/OAuth2/Storage/BaseTest.php @@ -7,6 +7,7 @@ abstract class BaseTest extends \PHPUnit_Framework_TestCase public function provideStorage() { $mysql = Bootstrap::getInstance()->getMysqlPdo(); + $postgres = Bootstrap::getInstance()->getPostgresPdo(); $sqlite = Bootstrap::getInstance()->getSqlitePdo(); $mongo = Bootstrap::getInstance()->getMongo(); $redis = Bootstrap::getInstance()->getRedisStorage(); @@ -20,6 +21,7 @@ abstract class BaseTest extends \PHPUnit_Framework_TestCase array($memory), array($sqlite), array($mysql), + array($postgres), array($mongo), array($redis), array($cassandra), diff --git a/test/lib/OAuth2/Storage/Bootstrap.php b/test/lib/OAuth2/Storage/Bootstrap.php index 52508c1..8d7fe0d 100644 --- a/test/lib/OAuth2/Storage/Bootstrap.php +++ b/test/lib/OAuth2/Storage/Bootstrap.php @@ -7,6 +7,7 @@ class Bootstrap protected static $instance; private $mysql; private $sqlite; + private $postgres; private $mongo; private $redis; private $cassandra; @@ -40,6 +41,38 @@ class Bootstrap return $this->sqlite; } + public function getPostgresPdo() + { + if (!$this->postgres) { + if (in_array('pgsql', \PDO::getAvailableDrivers())) { + $this->removePostgresDb(); + $this->createPostgresDb(); + if ($pdo = $this->getPostgresDriver()) { + $pdo->setAttribute(\PDO::ATTR_ERRMODE, \PDO::ERRMODE_EXCEPTION); + $this->populatePostgresDb($pdo); + $this->postgres = new Pdo($pdo); + } else { + $this->postgres = new NullStorage('Postgres', 'Unable to connect to postgres on localhost with user "postgres"'); + } + } else { + $this->postgres = new NullStorage('Postgres', 'Missing postgres PDO extension.'); + } + } + + return $this->postgres; + } + + public function getPostgresDriver() + { + try { + $pdo = new \PDO('pgsql:host=localhost;dbname=oauth2_server_php', 'postgres'); + + return $pdo; + } catch (\PDOException $e) { + exit($e->getMessage()); + } + } + public function getMemoryStorage() { return new Memory(json_decode(file_get_contents($this->configDir. '/storage.json'), true)); @@ -222,47 +255,78 @@ class Bootstrap $this->runPdoSql($pdo); } + private function removeMysqlDb(\PDO $pdo) + { + $pdo->exec('DROP DATABASE IF EXISTS oauth2_server_php'); + } + + private function createPostgresDb() + { + `createdb -O postgres oauth2_server_php`; + } + + private function populatePostgresDb(\PDO $pdo) + { + $this->runPdoSql($pdo); + } + + private function removePostgresDb() + { + `dropdb oauth2_server_php`; + } + public function runPdoSql(\PDO $pdo) { $pdo->exec('CREATE TABLE oauth_clients (client_id TEXT, client_secret TEXT, redirect_uri TEXT, grant_types TEXT, scope TEXT, user_id TEXT, public_key TEXT)'); - $pdo->exec('CREATE TABLE oauth_access_tokens (access_token TEXT, client_id TEXT, user_id TEXT, expires DATETIME, scope TEXT)'); - $pdo->exec('CREATE TABLE oauth_authorization_codes (authorization_code TEXT, client_id TEXT, user_id TEXT, redirect_uri TEXT, expires DATETIME, scope TEXT)'); - $pdo->exec('CREATE TABLE oauth_users (username TEXT, password TEXT, first_name TEXT, last_name TEXT, scope TEXT)'); - $pdo->exec('CREATE TABLE oauth_refresh_tokens (refresh_token TEXT, client_id TEXT, user_id TEXT, expires DATETIME, scope TEXT)'); + $pdo->exec('CREATE TABLE oauth_access_tokens (access_token TEXT, client_id TEXT, user_id TEXT, expires TIMESTAMP, scope TEXT)'); + $pdo->exec('CREATE TABLE oauth_authorization_codes (authorization_code TEXT, client_id TEXT, user_id TEXT, redirect_uri TEXT, expires TIMESTAMP, scope TEXT, id_token TEXT)'); + $pdo->exec('CREATE TABLE oauth_users (username TEXT, password TEXT, first_name TEXT, last_name TEXT, scope TEXT, email TEXT, email_verified BOOLEAN)'); + $pdo->exec('CREATE TABLE oauth_refresh_tokens (refresh_token TEXT, client_id TEXT, user_id TEXT, expires TIMESTAMP, scope TEXT)'); $pdo->exec('CREATE TABLE oauth_scopes (scope TEXT, is_default BOOLEAN)'); - $pdo->exec('CREATE TABLE oauth_public_keys (client_id TEXT, public_key TEXT, private_key TEXT, encryption_algorithm VARCHAR(100) DEFAULT "RS256")'); + $pdo->exec('CREATE TABLE oauth_public_keys (client_id TEXT, public_key TEXT, private_key TEXT, encryption_algorithm VARCHAR(100) DEFAULT \'RS256\')'); $pdo->exec('CREATE TABLE oauth_jwt (client_id VARCHAR(80), subject VARCHAR(80), public_key VARCHAR(2000))'); // set up scopes + $sql = 'INSERT INTO oauth_scopes (scope) VALUES (?)'; foreach (explode(' ', 'supportedscope1 supportedscope2 supportedscope3 supportedscope4 clientscope1 clientscope2 clientscope3') as $supportedScope) { - $pdo->exec(sprintf('INSERT INTO oauth_scopes (scope) VALUES ("%s")', $supportedScope)); + $pdo->prepare($sql)->execute(array($supportedScope)); } + $sql = 'INSERT INTO oauth_scopes (scope, is_default) VALUES (?, ?)'; foreach (array('defaultscope1', 'defaultscope2') as $defaultScope) { - $pdo->exec(sprintf('INSERT INTO oauth_scopes (scope, is_default) VALUES ("%s", 1)', $defaultScope)); + $pdo->prepare($sql)->execute(array($defaultScope, true)); } // set up clients - $pdo->exec('INSERT INTO oauth_clients (client_id, client_secret, scope) VALUES ("Test Client ID", "TestSecret", "clientscope1 clientscope2")'); - $pdo->exec('INSERT INTO oauth_clients (client_id, client_secret, scope) VALUES ("Test Client ID 2", "TestSecret", "clientscope1 clientscope2 clientscope3")'); - $pdo->exec('INSERT INTO oauth_clients (client_id, client_secret, scope) VALUES ("Test Default Scope Client ID", "TestSecret", "clientscope1 clientscope2")'); - $pdo->exec('INSERT INTO oauth_clients (client_id, client_secret, grant_types) VALUES ("oauth_test_client", "testpass", "implicit password")'); + $sql = 'INSERT INTO oauth_clients (client_id, client_secret, scope, grant_types) VALUES (?, ?, ?, ?)'; + $pdo->prepare($sql)->execute(array('Test Client ID', 'TestSecret', 'clientscope1 clientscope2', null)); + $pdo->prepare($sql)->execute(array('Test Client ID 2', 'TestSecret', 'clientscope1 clientscope2 clientscope3', null)); + $pdo->prepare($sql)->execute(array('Test Default Scope Client ID', 'TestSecret', 'clientscope1 clientscope2', null)); + $pdo->prepare($sql)->execute(array('oauth_test_client', 'testpass', null, 'implicit password')); // set up misc - $pdo->exec('INSERT INTO oauth_access_tokens (access_token, client_id) VALUES ("testtoken", "Some Client")'); - $pdo->exec('INSERT INTO oauth_authorization_codes (authorization_code, client_id) VALUES ("testcode", "Some Client")'); - $pdo->exec('INSERT INTO oauth_users (username, password) VALUES ("testuser", "password")'); - $pdo->exec('INSERT INTO oauth_public_keys (client_id, public_key, private_key, encryption_algorithm) VALUES ("ClientID_One", "client_1_public", "client_1_private", "RS256")'); - $pdo->exec('INSERT INTO oauth_public_keys (client_id, public_key, private_key, encryption_algorithm) VALUES ("ClientID_Two", "client_2_public", "client_2_private", "RS256")'); - $pdo->exec(sprintf('INSERT INTO oauth_public_keys (client_id, public_key, private_key, encryption_algorithm) VALUES (NULL, "%s", "%s", "RS256")', $this->getTestPublicKey(), $this->getTestPrivateKey())); - $pdo->exec(sprintf('INSERT INTO oauth_jwt (client_id, subject, public_key) VALUES ("oauth_test_client", "test_subject", "%s")', $this->getTestPublicKey())); - } + $sql = 'INSERT INTO oauth_access_tokens (access_token, client_id, user_id) VALUES (?, ?, ?)'; + $pdo->prepare($sql)->execute(array('testtoken', 'Some Client', null)); + $pdo->prepare($sql)->execute(array('accesstoken-openid-connect', 'Some Client', 'testuser')); - public function removeMysqlDb(\PDO $pdo) - { - $pdo->exec('DROP DATABASE IF EXISTS oauth2_server_php'); + $sql = 'INSERT INTO oauth_authorization_codes (authorization_code, client_id) VALUES (?, ?)'; + $pdo->prepare($sql)->execute(array('testcode', 'Some Client')); + + $sql = 'INSERT INTO oauth_users (username, password, email, email_verified) VALUES (?, ?, ?, ?)'; + $pdo->prepare($sql)->execute(array('testuser', 'password', 'testuser@test.com', true)); + + $sql = 'INSERT INTO oauth_public_keys (client_id, public_key, private_key, encryption_algorithm) VALUES (?, ?, ?, ?)'; + $pdo->prepare($sql)->execute(array('ClientID_One', 'client_1_public', 'client_1_private', 'RS256')); + $pdo->prepare($sql)->execute(array('ClientID_Two', 'client_2_public', 'client_2_private', 'RS256')); + + $sql = 'INSERT INTO oauth_public_keys (client_id, public_key, private_key, encryption_algorithm) VALUES (?, ?, ?, ?)'; + $pdo->prepare($sql)->execute(array(null, $this->getTestPublicKey(), $this->getTestPrivateKey(), 'RS256')); + + $sql = 'INSERT INTO oauth_jwt (client_id, subject, public_key) VALUES (?, ?, ?)'; + $pdo->prepare($sql)->execute(array('oauth_test_client', 'test_subject', $this->getTestPublicKey())); } + public function getSqliteDir() { return $this->configDir. '/test.sqlite'; |
