Skip to content

Commit d167020

Browse files
committed
Support for client certificate flow introduced, validate token response
1 parent 5146bbb commit d167020

9 files changed

+220
-10
lines changed

composer.json

+2-1
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,8 @@
2222
"ext-json": "*",
2323
"ext-simplexml": "*",
2424
"ext-dom": "*",
25-
"ext-libxml": "*"
25+
"ext-libxml": "*",
26+
"firebase/php-jwt": "*"
2627
},
2728
"require-dev": {
2829
"phpunit/phpunit": "^9"
+38
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
<?php
2+
3+
/**
4+
* Demonstrates how to authenticate SharePoint API via client certificate flow
5+
*
6+
* Steps:
7+
* 1. generate Self-Signed SSL Certificate
8+
* - generate a private key: openssl genrsa -out private.key 2048
9+
* - generate a public key: openssl req -new -x509 -key private.key -out publickey.cer -days 365
10+
* 2. upload the publickey.cer to your app in the Azure portal
11+
* 3. note the displayed thumbprint for the certificate
12+
* 4. initialize ClientContext instance and pass thumbprint and the contents of private.key
13+
* along with tenantName and clientId into withClientCertificate method
14+
*
15+
* Documentation: https://learn.microsoft.com/en-us/sharepoint/dev/solution-guidance/security-apponly-azuread
16+
*/
17+
18+
require_once __DIR__ . '/../vendor/autoload.php';
19+
$settings = include(__DIR__ . './../../tests/Settings.php');
20+
21+
use Office365\Runtime\Auth\ClientCredential;
22+
use Office365\SharePoint\ClientContext;
23+
24+
try {
25+
26+
$thumbprint = "054343442AC255DD07488910C7E000F92227FD98";
27+
$privateKey = file_get_contents("./private.key");
28+
29+
$credentials = new ClientCredential($settings['ClientId'], $settings['ClientSecret']);
30+
$ctx = (new ClientContext($settings['Url']))->withClientCertificate(
31+
$settings['TenantName'], $settings['ClientId'], $privateKey, $thumbprint);
32+
33+
$whoami = $ctx->getWeb()->getCurrentUser()->get()->executeQuery();
34+
print $whoami->getLoginName();
35+
}
36+
catch (Exception $e) {
37+
echo 'Authentication failed: ', $e->getMessage(), "\n";
38+
}

examples/SharePoint/DocSet/Create.php

+36
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
<?php
2+
require_once '../../vendor/autoload.php';
3+
$settings = include('../../../tests/Settings.php');
4+
5+
use Office365\Runtime\Auth\ClientCredential;
6+
use Office365\SharePoint\ClientContext;
7+
use Office365\SharePoint\DocumentManagement\DocumentSet\DocumentSet;
8+
9+
$credentials = new ClientCredential($settings['ClientId'], $settings['ClientSecret']);
10+
$siteUrl = $settings['TeamSiteUrl'];
11+
$client = (new ClientContext($siteUrl))->withCredentials($credentials);
12+
13+
//$docSetName = "DocSet_" . rand(1, 100000);
14+
15+
$docSetName = "Customers";
16+
$lib = $client->getWeb()->getLists()->getByTitle("HRDocs");
17+
$docSet = DocumentSet::create($client, $lib->getRootFolder(), $docSetName)->executeQuery();
18+
print($docSet->getProperty("ServerRelativeUrl"));
19+
20+
21+
//2. retrieve document set by url (document sets addressed bý folder url)
22+
$docSetUrl = "/sites/team/Shared Documents/Customers";
23+
$docSet = $client->getWeb()->getFolderByServerRelativeUrl($docSetUrl)->get()->executeQuery();
24+
25+
26+
//3. update document set
27+
$folderUrl = "/sites/team/Shared Documents/Customers";
28+
$docSet = $client->getWeb()->getFolderByServerRelativeUrl($folderUrl);
29+
$docSet->getListItemAllFields()->setProperty("CustomerType", "New")->update()->executeQuery();
30+
31+
32+
//4. delete document set
33+
$docSetUrl = "/sites/team/Shared Documents/Customers";
34+
$docSet = $client->getWeb()->getFolderByServerRelativeUrl($docSetUrl);
35+
$docSet->deleteObject()->executeQuery();
36+

examples/composer.json

+2-1
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,8 @@
22
"name": "vgrem/php-spo-examples",
33
"description": "Examples that demonstrate how to utilize library",
44
"require": {
5-
"fzaninotto/faker": "*"
5+
"fzaninotto/faker": "*",
6+
"firebase/php-jwt": "*"
67
},
78
"autoload": {
89
"psr-4": {

src/GraphServiceClient.php

+13
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
use Office365\Runtime\Actions\UpdateEntityQuery;
2424
use Office365\Runtime\Http\RequestOptions;
2525
use Office365\Teams\TeamCollection;
26+
use RuntimeException;
2627

2728

2829
/**
@@ -157,10 +158,22 @@ public function getServiceRootUrl()
157158
public function authenticateRequest(RequestOptions $options)
158159
{
159160
$token = call_user_func($this->acquireTokenFunc, $this);
161+
$this->validateToken($token);
160162
$headerVal = $token['token_type'] . ' ' . $token['access_token'];
161163
$options->ensureHeader('Authorization', $headerVal);
162164
}
163165

166+
private function validateToken($accessToken){
167+
if (isset($accessToken['error'])) {
168+
$message = $accessToken['error'];
169+
170+
if (isset($accessToken['error_description'])) {
171+
$message .= PHP_EOL . $accessToken['error_description'];
172+
}
173+
throw new RuntimeException($message);
174+
}
175+
}
176+
164177

165178
/**
166179
* @var ODataRequest $pendingRequest

src/Runtime/Auth/AADTokenProvider.php

+49-5
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
namespace Office365\Runtime\Auth;
55

66
use Exception;
7+
use Firebase\JWT\JWT;
78
use Office365\Runtime\Http\HttpMethod;
89
use Office365\Runtime\Http\RequestOptions;
910
use Office365\Runtime\Http\Requests;
@@ -20,6 +21,13 @@ class AADTokenProvider extends BaseTokenProvider
2021
*/
2122
private static $TokenEndpoint = '/oauth2/token';
2223

24+
25+
/**
26+
* @var string
27+
*/
28+
private static $TokenEndpointV2 = '/oauth2/v2.0/token';
29+
30+
2331
/**
2432
* @var string
2533
*/
@@ -45,6 +53,10 @@ public function __construct($tenant)
4553
$this->authorityUrl = self::$AuthorityUrl . $tenant;
4654
}
4755

56+
public function getTokenUrl($useV2){
57+
return $this->authorityUrl . ($useV2 ? self::$TokenEndpointV2: self::$TokenEndpoint);
58+
}
59+
4860

4961
/**
5062
* @param string $resource
@@ -87,6 +99,36 @@ public function acquireTokenForClientCredential($resource, $clientCredentials, $
8799
}
88100

89101

102+
/**
103+
* @param CertificateCredentials $credentials
104+
* @throws Exception
105+
*/
106+
public function acquireTokenForClientCertificate($credentials){
107+
$header = [
108+
'x5t' => base64_encode(hex2bin($credentials->Thumbprint)),
109+
];
110+
$now = time();
111+
$payload = [
112+
'aud' => $this->getTokenUrl(true),
113+
'exp' => $now + 360,
114+
'iat' => $now,
115+
'iss' => $credentials->ClientId,
116+
'jti' => bin2hex(random_bytes(20)),
117+
'nbf' => $now,
118+
'sub' => $credentials->ClientId,
119+
];
120+
$jwt = JWT::encode($payload, str_replace('\n', "\n", $credentials->PrivateKey), 'RS256', null, $header);
121+
122+
$params['client_assertion_type'] = 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer';
123+
$params['client_assertion'] = $jwt;
124+
$params['grant_type'] = "client_credentials";
125+
$params['scope'] = implode(" ", $credentials->Scope);
126+
127+
return $this->acquireToken($params, true);
128+
}
129+
130+
131+
90132
/**
91133
* @param string $resource
92134
* @param string $clientId
@@ -140,24 +182,26 @@ public function acquireTokenByAuthorizationCode($resource, $clientId, $clientSec
140182
/**
141183
* Acquires the access token
142184
* @param array $parameters
185+
* @param bool $useV2
143186
* @return mixed
144187
* @throws Exception
145188
*/
146-
public function acquireToken($parameters)
189+
public function acquireToken($parameters, $useV2=false)
147190
{
148-
$request = $this->prepareTokenRequest($parameters);
191+
$request = $this->prepareTokenRequest($parameters, $useV2);
149192
$response = Requests::execute($request);
150193
$response->validate();
151194
return $this->normalizeToken($response->getContent());
152195
}
153196

154197
/**
155-
* @param $parameters
198+
* @param array $parameters
199+
* @param bool $useV2
156200
* @return RequestOptions
157201
*/
158-
private function prepareTokenRequest($parameters)
202+
private function prepareTokenRequest($parameters, $useV2)
159203
{
160-
$tokenUrl = $this->authorityUrl . self::$TokenEndpoint;
204+
$tokenUrl = $this->getTokenUrl($useV2);
161205
$request = new RequestOptions($tokenUrl);
162206
$request->ensureHeader('content-Type', 'application/x-www-form-urlencoded');
163207
$request->Method = HttpMethod::Post;

src/Runtime/Auth/AuthenticationContext.php

+18-3
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@
22

33
namespace Office365\Runtime\Auth;
44

5-
65
use Exception;
76
use Office365\Runtime\Http\RequestOptions;
87

@@ -49,7 +48,7 @@ public function __construct($authorityUrl)
4948
}
5049

5150
/**
52-
* @param ClientCredential|UserCredentials $credential
51+
* @param ClientCredential|UserCredentials|CertificateCredentials $credential
5352
*/
5453
public function registerProvider($credential)
5554
{
@@ -58,14 +57,16 @@ public function registerProvider($credential)
5857
$this->acquireTokenForUser($credential->Username, $credential->Password);
5958
elseif ($credential instanceof ClientCredential)
6059
$this->acquireAppOnlyAccessToken($credential->ClientId, $credential->ClientSecret);
60+
elseif ($credential instanceof CertificateCredentials)
61+
$this->acquireAppOnlyAccessTokenWithCert($credential);
6162
else
6263
throw new Exception("Unknown credentials");
6364
};
6465
}
6566

6667

6768
/**
68-
* @var string
69+
* @param string $value
6970
*/
7071
public function setAccessToken($value)
7172
{
@@ -105,6 +106,19 @@ public function acquireAppOnlyAccessToken($clientId, $clientSecret){
105106
));
106107
}
107108

109+
/**
110+
* Acquire App-Only access token via client certificate flow
111+
* @param CertificateCredentials $credentials
112+
* @throws Exception
113+
*/
114+
public function acquireAppOnlyAccessTokenWithCert($credentials){
115+
if(!isset($credentials->Scope)){
116+
$credentials->Scope[] = "{$this->authorityUrl}/.default";
117+
}
118+
$this->provider = new AADTokenProvider($credentials->Tenant);
119+
$this->accessToken = $this->provider->acquireTokenForClientCertificate($credentials);
120+
}
121+
108122

109123

110124
/**
@@ -132,6 +146,7 @@ public function authenticateRequest(RequestOptions $request)
132146
/**
133147
* Ensures Authorization header is set
134148
* @param RequestOptions $options
149+
* @throws Exception
135150
*/
136151
protected function ensureAuthorizationHeader(RequestOptions $options)
137152
{
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
<?php
2+
3+
namespace Office365\Runtime\Auth;
4+
5+
class CertificateCredentials
6+
{
7+
8+
/**
9+
* @param string $tenant
10+
* @param string $clientId
11+
* @param string $privateKey
12+
* @param string $thumbprint
13+
* @param string[] $scope
14+
*/
15+
public function __construct($tenant, $clientId, $privateKey, $thumbprint, $scope=null)
16+
{
17+
$this->Tenant = $tenant;
18+
$this->ClientId = $clientId;
19+
$this->PrivateKey = $privateKey;
20+
$this->Thumbprint = $thumbprint;
21+
$this->Scope = $scope;
22+
}
23+
24+
25+
/**
26+
* @var string $Tenant
27+
*/
28+
public $Tenant;
29+
30+
/**
31+
* @var string $ClientId
32+
*/
33+
public $ClientId;
34+
35+
/**
36+
* @var string $PrivateKey
37+
*/
38+
public $PrivateKey;
39+
40+
/**
41+
* @var string $Thumbprint
42+
*/
43+
public $Thumbprint;
44+
45+
/**
46+
* @var string[] $Scope
47+
*/
48+
public $Scope;
49+
50+
}

src/SharePoint/ClientContext.php

+12
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
use Exception;
66
use Office365\Runtime\Auth\AuthenticationContext;
7+
use Office365\Runtime\Auth\CertificateCredentials;
78
use Office365\Runtime\Auth\ClientCredential;
89
use Office365\Runtime\Auth\IAuthenticationContext;
910
use Office365\Runtime\Auth\NetworkCredentialContext;
@@ -22,6 +23,7 @@
2223
use Office365\SharePoint\Search\SearchService;
2324
use Office365\SharePoint\Taxonomy\TaxonomyService;
2425
use Office365\SharePoint\UserProfiles\PeopleManager;
26+
use function PHPUnit\Framework\throwException;
2527

2628
/**
2729
* Client context for SharePoint API service
@@ -144,6 +146,16 @@ public function withCredentials($credential)
144146
return $this;
145147
}
146148

149+
/**
150+
* @return ClientContext
151+
*/
152+
public function withClientCertificate($tenant, $clientId, $privateKey, $thumbprint, $scopes=null){
153+
$this->authContext = new AuthenticationContext($this->baseUrl);
154+
$this->authContext->registerProvider(
155+
new CertificateCredentials($tenant, $clientId, $privateKey, $thumbprint, $scopes));
156+
return $this;
157+
}
158+
147159
/**
148160
* NTLM authentication flow (for SharePoint On-Premises)
149161
* @param UserCredentials $credential

0 commit comments

Comments
 (0)