Passed
Push — master ( f3f6fc...a51c4b )
by Arnold
06:17
created

Broker::request()   A

Complexity

Conditions 3
Paths 1

Size

Total Lines 12
Code Lines 7

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 7
CRAP Score 3

Importance

Changes 1
Bugs 0 Features 0
Metric Value
eloc 7
c 1
b 0
f 0
dl 0
loc 12
ccs 7
cts 7
cp 1
rs 10
cc 3
nc 1
nop 3
crap 3
1
<?php
2
3
declare(strict_types=1);
4
5
namespace Jasny\SSO\Broker;
6
7
use Jasny\Immutable;
8
9
/**
10
 * Single sign-on broker.
11
 *
12
 * The broker lives on the website visited by the user. The broken doesn't have any user credentials stored. Instead it
13
 * will talk to the SSO server in name of the user, verifying credentials and getting user information.
14
 */
15
class Broker
16
{
17
    use Immutable\With;
18
19
    /**
20
     * URL of SSO server.
21
     * @var string
22
     */
23
    protected $url;
24
25
    /**
26
     * My identifier, given by SSO provider.
27
     * @var string
28
     */
29
    protected $broker;
30
31
    /**
32
     * My secret word, given by SSO provider.
33
     * @var string
34
     */
35
    protected $secret;
36
37
    /**
38
     * @var bool
39
     */
40
    protected $initialized = false;
41
42
    /**
43
     * Session token of the client.
44
     * @var string|null
45
     */
46
    protected $token;
47
48
    /**
49
     * Verification code returned by the server.
50
     * @var string|null
51
     */
52
    protected $verificationCode;
53
54
    /**
55
     * @var \ArrayAccess<string,mixed>
56
     */
57
    protected $state;
58
59
    /**
60
     * @var Curl
61
     */
62
    protected $curl;
63
64
    /**
65
     * Class constructor
66
     *
67
     * @param string $url     Url of SSO server
68
     * @param string $broker  My identifier, given by SSO provider.
69
     * @param string $secret  My secret word, given by SSO provider.
70
     */
71 17
    public function __construct(string $url, string $broker, string $secret)
72
    {
73 17
        if (!(bool)preg_match('~^https?://~', $url)) {
74 1
            throw new \InvalidArgumentException("Invalid SSO server URL '$url'");
75
        }
76
77 17
        if ((bool)preg_match('/\W/', $broker)) {
78 1
            throw new \InvalidArgumentException("Invalid broker id '$broker': must be alphanumeric");
79
        }
80
81 17
        $this->url = $url;
82 17
        $this->broker = $broker;
83 17
        $this->secret = $secret;
84
85 17
        $this->state = new Cookies();
86 17
    }
87
88
    /**
89
     * Get a copy with a different handler for the user state (like cookie or session).
90
     *
91
     * @param \ArrayAccess<string,mixed> $handler
92
     * @return static
93
     */
94 17
    public function withTokenIn(\ArrayAccess $handler): self
95
    {
96 17
        return $this->withProperty('state', $handler);
97
    }
98
99
    /**
100
     * Set a custom wrapper for cURL.
101
     *
102
     * @param Curl $curl
103
     * @return static
104
     */
105 17
    public function withCurl(Curl $curl): self
106
    {
107 17
        return $this->withProperty('curl', $curl);
108
    }
109
110
    /**
111
     * Get Wrapped cURL.
112
     */
113 6
    protected function getCurl(): Curl
114
    {
115 6
        if (!isset($this->curl)) {
116
            $this->curl = new Curl(); // @codeCoverageIgnore
117
        }
118
119 6
        return $this->curl;
120
    }
121
122
    /**
123
     * Get the broker identifier.
124
     */
125 1
    public function getBrokerId(): string
126
    {
127 1
        return $this->broker;
128
    }
129
130
    /**
131
     * Get information from cookie.
132
     */
133 14
    protected function initialize(): void
134
    {
135 14
        if ($this->initialized) {
136 12
            return;
137
        }
138
139 14
        $this->token = $this->state[$this->getCookieName('token')] ?? null;
140 14
        $this->verificationCode = $this->state[$this->getCookieName('verify')] ?? null;
141 14
        $this->initialized = true;
142 14
    }
143
144
    /**
145
     * @return string|null
146
     */
147 10
    protected function getToken(): ?string
148
    {
149 10
        $this->initialize();
150
151 10
        return $this->token;
152
    }
153
154
    /**
155
     * @return string|null
156
     */
157 11
    protected function getVerificationCode(): ?string
158
    {
159 11
        $this->initialize();
160
161 11
        return $this->verificationCode;
162
    }
163
164
    /**
165
     * Get the cookie name.
166
     * The broker name is part of the cookie name. This resolves issues when multiple brokers are on the same domain.
167
     */
168 14
    protected function getCookieName(string $type): string
169
    {
170 14
        $brokerName = preg_replace('/[_\W]+/', '_', strtolower($this->broker));
171
172 14
        return "sso_{$type}_{$brokerName}";
173
    }
174
175
    /**
176
     * Generate session id from session key
177
     *
178
     * @throws NotAttachedException
179
     */
180 8
    public function getBearerToken(): string
181
    {
182 8
        $token = $this->getToken();
183 8
        $verificationCode = $this->getVerificationCode();
184
185 8
        if ($verificationCode === null) {
186 1
            throw new NotAttachedException("The client isn't attached to the SSO server for this broker. "
187 1
                . "Make sure that the '" . $this->getCookieName('verify') . "' cookie is set.");
188
        }
189
190 7
        return "SSO-{$this->broker}-{$token}-" . $this->generateChecksum("bearer:$verificationCode");
191
    }
192
193
    /**
194
     * Generate session token.
195
     */
196 2
    protected function generateToken(): void
197
    {
198 2
        $this->token = base_convert(bin2hex(random_bytes(32)), 16, 36);
199 2
        $this->state[$this->getCookieName('token')] = $this->token;
200 2
    }
201
202
    /**
203
     * Clears session token.
204
     */
205 1
    public function clearToken(): void
206
    {
207 1
        unset($this->state[$this->getCookieName('token')]);
208 1
        unset($this->state[$this->getCookieName('verify')]);
209
210 1
        $this->token = null;
211 1
        $this->verificationCode = null;
212 1
    }
213
214
    /**
215
     * Check if we have an SSO token.
216
     */
217 5
    public function isAttached(): bool
218
    {
219 5
        return $this->getVerificationCode() !== null;
220
    }
221
222
    /**
223
     * Get URL to attach session at SSO server.
224
     *
225
     * @param array<string,mixed> $params
226
     * @return string
227
     */
228 2
    public function getAttachUrl(array $params = []): string
229
    {
230 2
        if ($this->getToken() === null) {
231 2
            $this->generateToken();
232
        }
233
234
        $data = [
235 2
            'broker' => $this->broker,
236 2
            'token' => $this->getToken(),
237 2
            'checksum' => $this->generateChecksum('attach')
238
        ];
239
240 2
        return $this->url . "?" . http_build_query($data + $params);
241
    }
242
243
    /**
244
     * Verify attaching to the SSO server by providing the verification code.
245
     */
246 3
    public function verify(string $code): void
247
    {
248 3
        $this->initialize();
249
250 3
        if ($this->verificationCode === $code) {
251 1
            return;
252
        }
253
254 2
        if ($this->verificationCode !== null) {
255 1
            trigger_error("SSO attach already verified", E_USER_WARNING);
256 1
            return;
257
        }
258
259 1
        $this->verificationCode = $code;
260 1
        $this->state[$this->getCookieName('verify')] = $code;
261 1
    }
262
263
    /**
264
     * Generate checksum for a broker.
265
     */
266 9
    protected function generateChecksum(string $command): string
267
    {
268 9
        return base_convert(hash_hmac('sha256', $command . ':' . $this->token, $this->secret), 16, 36);
269
    }
270
271
    /**
272
     * Get the request url for a command
273
     *
274
     * @param string                     $path
275
     * @param array<string,mixed>|string $params   Query parameters
276
     * @return string
277
     */
278 6
    protected function getRequestUrl(string $path, $params = ''): string
279
    {
280 6
        $query = is_array($params) ? http_build_query($params) : $params;
281
282 6
        $base = $path[0] === '/'
283 6
            ? preg_replace('~^(\w+://[^/]+).*~', '$1', $this->url)
284 6
            : preg_replace('~/[^/]*$~', '', $this->url);
285
286 6
        return $base . '/' . ltrim($path, '/') . ($query !== '' ? '?' . $query : '');
287
    }
288
289
290
    /**
291
     * Send an HTTP request to the SSO server.
292
     *
293
     * @param string                     $method  HTTP method: 'GET', 'POST', 'DELETE'
294
     * @param string                     $path    Relative path
295
     * @param array<string,mixed>|string $data    Query or post parameters
296
     * @return mixed
297
     * @throws RequestException
298
     */
299 6
    public function request(string $method, string $path, $data = '')
300
    {
301 6
        $url = $this->getRequestUrl($path, $method === 'POST' ? '' : $data);
302
        $headers = [
303 6
            'Accept: application/json',
304 6
            'Authorization: Bearer ' . $this->getBearerToken()
305
        ];
306
307 6
        ['httpCode' => $httpCode, 'contentType' => $contentType, 'body' => $body] =
308 6
            $this->getCurl()->request($method, $url, $headers, $method === 'POST' ? $data : '');
309
310 6
        return $this->handleResponse($httpCode, $contentType, $body);
311
    }
312
313
    /**
314
     * Handle the response of the cURL request.
315
     *
316
     * @param int    $httpCode  HTTP status code
317
     * @param string $ctHeader  Content-Type header
318
     * @param string $body      Response body
319
     * @return mixed
320
     * @throws RequestException
321
     */
322 6
    protected function handleResponse(int $httpCode, string $ctHeader, string $body)
323
    {
324 6
        if ($httpCode === 204) {
325 1
            return null;
326
        }
327
328 5
        [$contentType] = explode(';', $ctHeader, 2);
329
330 5
        if ($contentType != 'application/json') {
331 1
            throw new RequestException(
332 1
                "Expected 'application/json' response, got '$contentType'",
333 1
                500,
334 1
                new RequestException($body, $httpCode)
335
            );
336
        }
337
338
        try {
339 4
            $data = json_decode($body, true, 512, JSON_THROW_ON_ERROR);
340 1
        } catch (\JsonException $exception) {
341 1
            throw new RequestException("Invalid JSON response from server", 500, $exception);
342
        }
343
344 3
        if ($httpCode >= 400) {
345 1
            throw new RequestException($data['error'] ?? $body, $httpCode);
346
        }
347
348 2
        return $data;
349
    }
350
}
351