From d67a1731693144bb963d52eba161b5167f77fb70 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20Cetkovsk=C3=BD?= Date: Wed, 23 Jan 2019 19:06:25 +0100 Subject: [PATCH 1/3] Add support for v2.0 of AAD endpoints. Grab most urls from /.well-known/openid-configuration endpoint instead of having them hard coded. Resolved duplication of code in AccessToken::__construct and Azure::validateAccessToken --- src/Provider/Azure.php | 134 ++++++++++++++++++++++++++++---------- src/Token/AccessToken.php | 21 +----- 2 files changed, 102 insertions(+), 53 deletions(-) diff --git a/src/Provider/Azure.php b/src/Provider/Azure.php index 2f8e0ab..50969cf 100644 --- a/src/Provider/Azure.php +++ b/src/Provider/Azure.php @@ -13,13 +13,13 @@ class Azure extends AbstractProvider { - use BearerAuthorizationTrait; - - public $urlLogin = 'https://login.microsoftonline.com/'; + const ENDPOINT_VERSION_1_0 = '1.0'; + const ENDPOINT_VERSION_2_0 = '2.0'; - public $pathAuthorize = '/oauth2/authorize'; + use BearerAuthorizationTrait; - public $pathToken = '/oauth2/token'; + /** @var array|null */ + protected $openIdConfiguration; public $scope = []; @@ -27,6 +27,8 @@ class Azure extends AbstractProvider public $tenant = 'common'; + public $defaultEndPointVersion = self::ENDPOINT_VERSION_1_0; + public $urlAPI = 'https://graph.windows.net/'; public $resource = ''; @@ -41,20 +43,55 @@ public function __construct(array $options = [], array $collaborators = []) $this->grantFactory->setGrant('jwt_bearer', new JwtBearer()); } + /** + * @param string $tenant + * @param string $version + */ + protected function getOpenIdConfiguration($tenant, $version) { + if (!is_array($this->openIdConfiguration)) { + $this->openIdConfiguration = []; + } + if (!array_key_exists($tenant, $this->openIdConfiguration)) { + $this->openIdConfiguration[$tenant] = []; + } + if (!array_key_exists($version, $this->openIdConfiguration[$tenant])) { + $versionInfix = $this->getVersionUriInfix($version); + $openIdConfigurationUri = 'https://login.microsoftonline.com/' . $tenant . $versionInfix . '/.well-known/openid-configuration'; + $factory = $this->getRequestFactory(); + $request = $factory->getRequestWithOptions( + 'get', + $openIdConfigurationUri, + [] + ); + $response = $this->getParsedResponse($request); + $this->openIdConfiguration[$tenant][$version] = $response; + } + + return $this->openIdConfiguration[$tenant][$version]; + } + public function getBaseAuthorizationUrl() { - return $this->urlLogin . $this->tenant . $this->pathAuthorize; + $openIdConfiguration = $this->getOpenIdConfiguration($this->tenant, $this->defaultEndPointVersion); + return $openIdConfiguration['authorization_endpoint']; } public function getBaseAccessTokenUrl(array $params) { - return $this->urlLogin . $this->tenant . $this->pathToken; + $openIdConfiguration = $this->getOpenIdConfiguration($this->tenant, $this->defaultEndPointVersion); + return $openIdConfiguration['token_endpoint']; } public function getAccessToken($grant, array $options = []) { - if ($this->authWithResource) { - $options['resource'] = $this->resource ? $this->resource : $this->urlAPI; + if ($this->defaultEndPointVersion != self::ENDPOINT_VERSION_2_0) { + // Version 2.0 does not support the resources parameter + // https://docs.microsoft.com/en-us/azure/active-directory/develop/v2-oauth2-auth-code-flow + // while version 1.0 does recommend it + // https://docs.microsoft.com/en-us/azure/active-directory/develop/v1-protocols-oauth-code + if ($this->authWithResource) { + $options['resource'] = $this->resource ? $this->resource : $this->urlAPI; + } } return parent::getAccessToken($grant, $options); } @@ -99,6 +136,24 @@ public function getObjects($tenant, $ref, &$accessToken, $headers = []) return $objects; } + /** + * @param $accessToken AccessToken|null + * @return string + */ + public function getRootMicrosoftGraphUri($accessToken) + { + if (is_null($accessToken)) { + $tenant = $this->tenant; + $version = $this->defaultEndPointVersion; + } else { + $idTokenClaims = $accessToken->getIdTokenClaims(); + $tenant = array_key_exists('tid', $idTokenClaims) ? $idTokenClaims['tid'] : $this->tenant; + $version = array_key_exists('ver', $idTokenClaims) ? $idTokenClaims['ver'] : $this->defaultEndPointVersion; + } + $openIdConfiguration = $this->getOpenIdConfiguration($tenant, $version); + return 'https://' . $openIdConfiguration['msgraph_host']; + } + public function get($ref, &$accessToken, $headers = []) { $response = $this->request('get', $ref, $accessToken, ['headers' => $headers]); @@ -189,7 +244,10 @@ public function getClientId() */ public function getLogoutUrl($post_logout_redirect_uri) { - return 'https://login.microsoftonline.com/' . $this->tenant . '/oauth2/logout?post_logout_redirect_uri=' . rawurlencode($post_logout_redirect_uri); + $openIdConfiguration = $this->getOpenIdConfiguration($this->tenant, $this->defaultEndPointVersion); + $logoutUri = $openIdConfiguration['end_session_endpoint']; + + return $logoutUri . '?post_logout_redirect_uri=' . rawurlencode($post_logout_redirect_uri); } /** @@ -204,6 +262,19 @@ public function validateAccessToken($accessToken) $keys = $this->getJwtVerificationKeys(); $tokenClaims = (array)JWT::decode($accessToken, $keys, ['RS256']); + $this->validateTokenClaims($tokenClaims); + + return $tokenClaims; + } + + /** + * Validate the access token claims from an access token you received in your application. + * + * @param $tokenClaims array The token claims from an access token you received in the authorization header. + * + * @return void + */ + public function validateTokenClaims($tokenClaims) { if ($this->getClientId() != $tokenClaims['aud'] && $this->getClientId() != $tokenClaims['appid']) { throw new \RuntimeException('The client_id / audience is invalid!'); } @@ -214,19 +285,13 @@ public function validateAccessToken($accessToken) if ('common' == $this->tenant) { $this->tenant = $tokenClaims['tid']; - - $tenant = $this->getTenantDetails($this->tenant); - if ($tokenClaims['iss'] != $tenant['issuer']) { - throw new \RuntimeException('Invalid token issuer!'); - } - } else { - $tenant = $this->getTenantDetails($this->tenant); - if ($tokenClaims['iss'] != $tenant['issuer']) { - throw new \RuntimeException('Invalid token issuer!'); - } } - return $tokenClaims; + $version = array_key_exists('ver', $tokenClaims) ? $tokenClaims['ver'] : $this->defaultEndPointVersion; + $tenant = $this->getTenantDetails($this->tenant, $version); + if ($tokenClaims['iss'] != $tenant['issuer']) { + throw new \RuntimeException('Invalid token issuer (tokenClaims[iss]' . $tokenClaims['iss'] . ', tenant[issuer] ' . $tenant['issuer'] . ')!'); + } } /** @@ -236,8 +301,11 @@ public function validateAccessToken($accessToken) */ public function getJwtVerificationKeys() { + $openIdConfiguration = $this->getOpenIdConfiguration($this->tenant, $this->defaultEndPointVersion); + $keysUri = $openIdConfiguration['jwks_uri']; + $factory = $this->getRequestFactory(); - $request = $factory->getRequestWithOptions('get', 'https://login.windows.net/common/discovery/keys', []); + $request = $factory->getRequestWithOptions('get', $keysUri, []); $response = $this->getParsedResponse($request); @@ -278,25 +346,25 @@ public function getJwtVerificationKeys() return $keys; } + protected function getVersionUriInfix($version) + { + return + ($version == self::ENDPOINT_VERSION_2_0) + ? '/v' . self::ENDPOINT_VERSION_2_0 + : ''; + } + /** * Get the specified tenant's details. * * @param string $tenant + * @param string|null $version * * @return array */ - public function getTenantDetails($tenant) + public function getTenantDetails($tenant, $version) { - $factory = $this->getRequestFactory(); - $request = $factory->getRequestWithOptions( - 'get', - 'https://login.windows.net/' . $tenant . '/.well-known/openid-configuration', - [] - ); - - $response = $this->getParsedResponse($request); - - return $response; + return $this->getOpenIdConfiguration($this->tenant, $this->defaultEndPointVersion); } protected function checkResponse(ResponseInterface $response, $data) diff --git a/src/Token/AccessToken.php b/src/Token/AccessToken.php index 4033eb5..3899f83 100644 --- a/src/Token/AccessToken.php +++ b/src/Token/AccessToken.php @@ -38,27 +38,8 @@ public function __construct(array $options, $provider) } catch (JWT_Exception $e) { throw new RuntimeException('Unable to parse the id_token!'); } - if ($provider->getClientId() != $idTokenClaims['aud']) { - throw new RuntimeException('The audience is invalid!'); - } - if ($idTokenClaims['nbf'] > time() || $idTokenClaims['exp'] < time()) { - // Additional validation is being performed in firebase/JWT itself - throw new RuntimeException('The id_token is invalid!'); - } - - if ('common' == $provider->tenant) { - $provider->tenant = $idTokenClaims['tid']; - $tenant = $provider->getTenantDetails($provider->tenant); - if ($idTokenClaims['iss'] != $tenant['issuer']) { - throw new RuntimeException('Invalid token issuer!'); - } - } else { - $tenant = $provider->getTenantDetails($provider->tenant); - if ($idTokenClaims['iss'] != $tenant['issuer']) { - throw new RuntimeException('Invalid token issuer!'); - } - } + $provider->validateTokenClaims($idTokenClaims); $this->idTokenClaims = $idTokenClaims; } From 6d8c8ef732953daef77fcbf43a3fa1edef8b45d7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20Cetkovsk=C3=BD?= Date: Mon, 18 Mar 2019 19:07:01 +0100 Subject: [PATCH 2/3] Updated README.md to reflect added support of v2 API. --- README.md | 117 +++++++++++++++++++++++++++++++++++++----------------- 1 file changed, 81 insertions(+), 36 deletions(-) diff --git a/README.md b/README.md index 7e84211..1eeea65 100644 --- a/README.md +++ b/README.md @@ -11,11 +11,12 @@ This package provides [Azure Active Directory](https://azure.microsoft.com/en-us - [Authorization Code Flow](#authorization-code-flow) - [Advanced flow](#advanced-flow) - [Using custom parameters](#using-custom-parameters) + - [**NEW** - Call on behalf of a token provided by another app](#call-on-behalf-of-a-token-provided-by-another-app) - [**NEW** - Logging out](#logging-out) - [Making API Requests](#making-api-requests) - [Variables](#variables) - [Resource Owner](#resource-owner) -- [Microsoft Graph](#microsoft-graph) +- [**UPDATED** - Microsoft Graph](#microsoft-graph) - [**NEW** - Protecting your API - *experimental*](#protecting-your-api---experimental) - [Azure Active Directory B2C - *experimental*](#azure-active-directory-b2c---experimental) - [Multipurpose refresh tokens - *experimental*](#multipurpose-refresh-tokens---experimental) @@ -46,45 +47,66 @@ $provider = new TheNetworg\OAuth2\Client\Provider\Azure([ 'redirectUri' => 'https://example.com/callback-url' ]); -if (!isset($_GET['code'])) { +// Set to use v2 API, skip the line or set the value to Azure::ENDPOINT_VERSION_1_0 if willing to use v1 API +$provider->defaultEndPointVersion = Azure::ENDPOINT_VERSION_2_0; - // If we don't have an authorization code then get one - $authUrl = $provider->getAuthorizationUrl(); - $_SESSION['oauth2state'] = $provider->getState(); - header('Location: '.$authUrl); - exit; +$baseGraphUri = $provider->getRootMicrosoftGraphUri(null); +$provider->scope = 'openid profile email offline_access ' . $baseGraphUri . '/User.Read'; -// Check given state against previously stored one to mitigate CSRF attack -} elseif (empty($_GET['state']) || ($_GET['state'] !== $_SESSION['oauth2state'])) { +if (isset($_GET['code']) && isset($_SESSION['OAuth2.state']) && isset($_GET['state'])) { + if ($_GET['state'] == $_SESSION['OAuth2.state']) { + unset($_SESSION['OAuth2.state']); - unset($_SESSION['oauth2state']); - exit('Invalid state'); + // Try to get an access token (using the authorization code grant) + /** @var AccessToken $token */ + $token = $provider->getAccessToken('authorization_code', [ + 'scope' => $provider->scope, + 'code' => $_GET['code'], + ]); -} else { - - // Try to get an access token (using the authorization code grant) - $token = $provider->getAccessToken('authorization_code', [ - 'code' => $_GET['code'], - 'resource' => 'https://graph.windows.net', - ]); - - // Optional: Now you have a token you can look up a users profile data - try { - - // We got an access token, let's now get the user's details - $me = $provider->get("me", $token); - - // Use these details to create a new profile - printf('Hello %s!', $me['givenName']); + // Verify token + // Save it to local server session data + + return $token->getToken(); + } else { + echo 'Invalid state'; - } catch (Exception $e) { - - // Failed to get user details - exit('Oh dear...'); + return null; } - - // Use this to interact with an API on the users behalf - echo $token->getToken(); +} else { + // // Check local server's session data for a token + // // and verify if still valid + // /** @var ?AccessToken $token */ + // $token = // token cached in session data, null if not found; + // + // if (isset($token)) { + // $me = $provider->get($provider->getRootMicrosoftGraphUri($token) . '/v1.0/me', $token); + // $userEmail = $me['mail']; + // + // if ($token->hasExpired()) { + // if (!is_null($token->getRefreshToken())) { + // $token = $provider->getAccessToken('refresh_token', [ + // 'scope' => $provider->scope, + // 'refresh_token' => $token->getRefreshToken() + // ]); + // } else { + // $token = null; + // } + // } + //} + // + // If the token is not found in + // if (!isset($token)) { + $authorizationUrl = $provider->getAuthorizationUrl(['scope' => $provider->scope]); + + $_SESSION['OAuth2.state'] = $provider->getState(); + + header('Location: ' . $authorizationUrl); + + exit; + // } + + return $token->getToken(); } ``` @@ -111,6 +133,24 @@ $logoutUrl = $provider->getLogoutUrl($post_logout_redirect_uri); header('Location: '.$logoutUrl); // Redirect the user to the generated URL ``` +#### Call on behalf of a token provided by another app + +```php +$suppliedToken = ''; // Use token provided by the other app + +$provider = xxxxx;// Initialize provider + +// Call this to get claims +// $claims = $provider->validateAccessToken($suppliedToken); + +/** @var AccessToken $token */ +$token = $provider->getAccessToken('jwt_bearer', [ + 'scope' => $provider->scope, + 'assertion' => $suppliedToken, + 'requested_token_use' => 'on_behalf_of', +]); +``` + ## Making API Requests This library also provides easy interface to make it easier to interact with [Azure Graph API](https://msdn.microsoft.com/en-us/library/azure/hh974476.aspx) and [Microsoft Graph](http://graph.microsoft.io), the following methods are available on `provider` object (it also handles automatic token refresh flow should it be needed during making the request): @@ -151,8 +191,12 @@ The exposed attributes and function are: ## Microsoft Graph Calling [Microsoft Graph](http://graph.microsoft.io/) is very simple with this library. After provider initialization simply change the API URL followingly (replace `v1.0` with your desired version): ```php -$provider->urlAPI = "https://graph.microsoft.com/v1.0/"; -$provider->resource = "https://graph.microsoft.com/"; +// Mention Microsoft Graph scope when initializing the provider +$baseGraphUri = $provider->getRootMicrosoftGraphUri(null); +$provider->scope = 'your scope ' . $baseGraphUri . '/User.Read'; + +// Call a query +$provider->get($provider->getRootMicrosoftGraphUri($token) . '/v1.0/me', $token); ``` After that, when requesting access token, refresh token or so, provide the `resource` with value `https://graph.microsoft.com/` in order to be able to make calls to the Graph (see more about `resource` [here](#advanced-flow)). @@ -223,6 +267,7 @@ We accept contributions via [Pull Requests on Github](https://github.com/thenetw - [Jan Hajek](https://github.com/hajekj) ([TheNetw.org](https://thenetw.org)) - [Vittorio Bertocci](https://github.com/vibronet) (Microsoft) - Thanks for the splendid support while implementing #16 +- [Martin Cetkovský](https://github.com/mcetkovsky) ([cetkovsky.eu](https://www.cetkovsky.eu)] - [All Contributors](https://github.com/thenetworg/oauth2-azure/contributors) ## Support From 81050e5de3757cf7be964bcf4853ee74878d7edf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20Cetkovsk=C3=BD?= Date: Mon, 18 Mar 2019 19:10:50 +0100 Subject: [PATCH 3/3] Updated README.md to reflect added support of v2 API. --- README.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 1eeea65..0c0ba2a 100644 --- a/README.md +++ b/README.md @@ -136,7 +136,9 @@ header('Location: '.$logoutUrl); // Redirect the user to the generated URL #### Call on behalf of a token provided by another app ```php -$suppliedToken = ''; // Use token provided by the other app +// Use token provided by the other app +// Make sure the other app mentioned this app in the scope when requesting the token +$suppliedToken = ''; $provider = xxxxx;// Initialize provider