@fleetbase/solid-engine 0.0.3 → 0.0.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/ACL_SOLUTION.md +72 -0
- package/CSS_SCOPE_ISSUE.md +140 -0
- package/HOTFIX_SYNTAX_ERROR.md +100 -0
- package/LICENSE.md +651 -21
- package/MANUAL_ACL_SETUP.md +135 -0
- package/README.md +74 -27
- package/REFACTORING_SUMMARY.md +330 -0
- package/VERIFICATION_CHECKLIST.md +82 -0
- package/addon/components/modals/create-solid-folder.hbs +29 -0
- package/addon/components/modals/import-solid-resources.hbs +85 -0
- package/addon/controllers/data/content.js +17 -0
- package/addon/controllers/data/index.js +219 -0
- package/addon/controllers/home.js +84 -0
- package/addon/engine.js +1 -24
- package/addon/extension.js +26 -0
- package/addon/routes/data/content.js +11 -0
- package/addon/routes/data/index.js +17 -0
- package/addon/routes.js +2 -7
- package/addon/styles/solid-engine.css +1 -2
- package/addon/templates/account.hbs +3 -3
- package/addon/templates/application.hbs +2 -12
- package/addon/templates/data/content.hbs +48 -0
- package/addon/templates/{pods/explorer.hbs → data/index.hbs} +6 -5
- package/addon/templates/home.hbs +168 -10
- package/app/components/modals/{backup-pod.js → create-solid-folder.js} +1 -1
- package/app/components/modals/{resync-pod.js → import-solid-resources.js} +1 -1
- package/app/components/modals/{create-pod.js → setup-css-credentials.js} +1 -1
- package/composer.json +5 -11
- package/extension.json +2 -2
- package/index.js +0 -11
- package/package.json +9 -9
- package/server/migrations/2024_12_21_add_css_credentials_to_solid_identities_table.php +32 -0
- package/server/src/Client/OpenIDConnectClient.php +686 -15
- package/server/src/Client/SolidClient.php +104 -8
- package/server/src/Http/Controllers/DataController.php +261 -0
- package/server/src/Http/Controllers/OIDCController.php +42 -8
- package/server/src/Http/Controllers/SolidController.php +179 -85
- package/server/src/Models/SolidIdentity.php +13 -3
- package/server/src/Services/AclService.php +146 -0
- package/server/src/Services/PodService.php +863 -0
- package/server/src/Services/ResourceSyncService.php +336 -0
- package/server/src/Services/VehicleSyncService.php +289 -0
- package/server/src/Support/Utils.php +10 -0
- package/server/src/routes.php +25 -1
- package/addon/components/modals/backup-pod.hbs +0 -3
- package/addon/components/modals/create-pod.hbs +0 -5
- package/addon/components/modals/resync-pod.hbs +0 -3
- package/addon/controllers/pods/explorer/content.js +0 -12
- package/addon/controllers/pods/explorer.js +0 -149
- package/addon/controllers/pods/index/pod.js +0 -12
- package/addon/controllers/pods/index.js +0 -137
- package/addon/routes/pods/explorer/content.js +0 -10
- package/addon/routes/pods/explorer.js +0 -44
- package/addon/routes/pods/index/pod.js +0 -3
- package/addon/routes/pods/index.js +0 -21
- package/addon/templates/pods/explorer/content.hbs +0 -19
- package/addon/templates/pods/index/pod.hbs +0 -11
- package/addon/templates/pods/index.hbs +0 -19
- package/server/src/LegacyClient/Identity/IdentityProvider.php +0 -174
- package/server/src/LegacyClient/Identity/Profile.php +0 -18
- package/server/src/LegacyClient/OIDCClient.php +0 -350
- package/server/src/LegacyClient/Profile/WebID.php +0 -26
- package/server/src/LegacyClient/SolidClient.php +0 -271
|
@@ -2,22 +2,35 @@
|
|
|
2
2
|
|
|
3
3
|
namespace Fleetbase\Solid\Client;
|
|
4
4
|
|
|
5
|
+
use Firebase\JWT\JWK;
|
|
6
|
+
use Firebase\JWT\JWT;
|
|
5
7
|
use Fleetbase\Solid\Models\SolidIdentity;
|
|
6
8
|
use Illuminate\Http\Client\Response;
|
|
9
|
+
use Illuminate\Support\Facades\Log;
|
|
7
10
|
use Illuminate\Support\Facades\Redis;
|
|
11
|
+
use Illuminate\Support\Facades\Storage;
|
|
8
12
|
use Illuminate\Support\Str;
|
|
9
13
|
use Jumbojett\OpenIDConnectClient as BaseOpenIDConnectClient;
|
|
10
14
|
use Jumbojett\OpenIDConnectClientException;
|
|
11
15
|
|
|
12
|
-
const CLIENT_NAME = 'Fleetbase';
|
|
16
|
+
const CLIENT_NAME = 'Fleetbase-v2'; // v2: Added webid scope support
|
|
13
17
|
final class OpenIDConnectClient extends BaseOpenIDConnectClient
|
|
14
18
|
{
|
|
15
19
|
private ?SolidClient $solid;
|
|
16
20
|
private ?SolidIdentity $identity;
|
|
17
21
|
private ?\stdClass $openIdConfig;
|
|
22
|
+
private string $code;
|
|
23
|
+
private static $dpopKeyPairCache = [];
|
|
18
24
|
|
|
19
25
|
public function __construct(array $options = [])
|
|
20
26
|
{
|
|
27
|
+
// Call parent constructor to initialize the OIDC client properly
|
|
28
|
+
// Pass null for provider_url as we'll set it later in create()
|
|
29
|
+
parent::__construct(null, null, null, null);
|
|
30
|
+
|
|
31
|
+
// Set the scope during registration
|
|
32
|
+
$this->addRegistrationParam(['scope' => 'openid webid offline_access']);
|
|
33
|
+
|
|
21
34
|
$this->solid = data_get($options, 'solid');
|
|
22
35
|
$this->identity = data_get($options, 'identity');
|
|
23
36
|
if ($this->identity instanceof SolidIdentity) {
|
|
@@ -25,19 +38,65 @@ final class OpenIDConnectClient extends BaseOpenIDConnectClient
|
|
|
25
38
|
}
|
|
26
39
|
$this->setCodeChallengeMethod('S256');
|
|
27
40
|
$this->setClientName(data_get($options, 'clientName', CLIENT_NAME));
|
|
28
|
-
|
|
29
|
-
|
|
41
|
+
|
|
42
|
+
// Only set client credentials if they are provided
|
|
43
|
+
$clientID = data_get($options, 'clientID');
|
|
44
|
+
if ($clientID !== null) {
|
|
45
|
+
$this->setClientID($clientID);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
$clientSecret = data_get($options, 'clientSecret');
|
|
49
|
+
if ($clientSecret !== null) {
|
|
50
|
+
$this->setClientSecret($clientSecret);
|
|
51
|
+
}
|
|
30
52
|
|
|
31
|
-
// Restore client credentials
|
|
32
|
-
if (isset($options['restore'])) {
|
|
33
|
-
|
|
53
|
+
// Restore client credentials if requested or if identity is provided without clientID
|
|
54
|
+
if (isset($options['restore']) || ($this->identity instanceof SolidIdentity && $clientID === null)) {
|
|
55
|
+
try {
|
|
56
|
+
$this->restoreClientCredentials();
|
|
57
|
+
} catch (\Exception $e) {
|
|
58
|
+
// Credentials not yet saved, which is fine during initial registration
|
|
59
|
+
Log::debug('[OIDC] Client credentials not yet available for restoration', [
|
|
60
|
+
'identity_uuid' => $this->identity?->uuid,
|
|
61
|
+
'error' => $e->getMessage(),
|
|
62
|
+
]);
|
|
63
|
+
}
|
|
34
64
|
}
|
|
35
65
|
}
|
|
36
66
|
|
|
37
67
|
public static function create(array $options = []): OpenIDConnectClient
|
|
38
68
|
{
|
|
39
69
|
$client = new static($options);
|
|
70
|
+
$solid = data_get($options, 'solid');
|
|
71
|
+
|
|
72
|
+
// For OIDC discovery, use the configured OIDC issuer URL
|
|
73
|
+
// This allows using HTTPS for OIDC (through nginx) while using HTTP for API calls
|
|
74
|
+
if ($solid instanceof \Fleetbase\Solid\Client\SolidClient) {
|
|
75
|
+
$oidcIssuer = config('solid.oidc_issuer', $solid->getServerUrl());
|
|
76
|
+
$client->setProviderURL($oidcIssuer);
|
|
77
|
+
Log::debug('[OIDC] Using provider URL for discovery', [
|
|
78
|
+
'provider_url' => $oidcIssuer,
|
|
79
|
+
'config_value' => config('solid.oidc_issuer'),
|
|
80
|
+
'fallback' => $solid->getServerUrl(),
|
|
81
|
+
]);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
Log::debug('[OIDC] About to fetch configuration', [
|
|
85
|
+
'provider_url_before_fetch' => $client->getProviderURL(),
|
|
86
|
+
]);
|
|
87
|
+
|
|
40
88
|
$openIdConfig = $client->getOpenIdConfiguration();
|
|
89
|
+
|
|
90
|
+
Log::debug('[OIDC] Received configuration', [
|
|
91
|
+
'config' => $openIdConfig,
|
|
92
|
+
'has_issuer' => isset($openIdConfig->issuer),
|
|
93
|
+
'issuer' => $openIdConfig->issuer ?? 'NOT SET',
|
|
94
|
+
]);
|
|
95
|
+
|
|
96
|
+
if (!isset($openIdConfig->issuer)) {
|
|
97
|
+
throw new \Exception('OIDC configuration does not contain issuer property. Response: ' . json_encode($openIdConfig));
|
|
98
|
+
}
|
|
99
|
+
|
|
41
100
|
$client->setProviderURL($openIdConfig->issuer);
|
|
42
101
|
$client->setIssuer($openIdConfig->issuer);
|
|
43
102
|
$client->providerConfigParam((array) $openIdConfig);
|
|
@@ -45,6 +104,52 @@ final class OpenIDConnectClient extends BaseOpenIDConnectClient
|
|
|
45
104
|
return $client;
|
|
46
105
|
}
|
|
47
106
|
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Override parent fetchURL to disable SSL verification in development.
|
|
111
|
+
*
|
|
112
|
+
* The parent class uses cURL to make HTTP requests but doesn't disable
|
|
113
|
+
* SSL verification, which causes issues with self-signed certificates
|
|
114
|
+
* in local development.
|
|
115
|
+
*/
|
|
116
|
+
protected function fetchURL(string $url, string $post_body = null, array $headers = [])
|
|
117
|
+
{
|
|
118
|
+
$ch = curl_init();
|
|
119
|
+
|
|
120
|
+
if ($post_body !== null) {
|
|
121
|
+
curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'POST');
|
|
122
|
+
curl_setopt($ch, CURLOPT_POSTFIELDS, $post_body);
|
|
123
|
+
$content_type = is_object(json_decode($post_body, false)) ? 'application/json' : 'application/x-www-form-urlencoded';
|
|
124
|
+
$headers[] = "Content-Type: $content_type";
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
if (count($headers) > 0) {
|
|
128
|
+
curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Disable SSL verification in development only (self-signed certificates)
|
|
132
|
+
if (app()->environment('local', 'development')) {
|
|
133
|
+
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
|
|
134
|
+
curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, false);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
curl_setopt($ch, CURLOPT_URL, $url);
|
|
138
|
+
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
|
139
|
+
curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true);
|
|
140
|
+
curl_setopt($ch, CURLOPT_USERAGENT, $this->getUserAgent());
|
|
141
|
+
|
|
142
|
+
$response = curl_exec($ch);
|
|
143
|
+
$error = curl_error($ch);
|
|
144
|
+
curl_close($ch);
|
|
145
|
+
|
|
146
|
+
if ($error) {
|
|
147
|
+
throw new \Exception("Curl error: $error");
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
return $response;
|
|
151
|
+
}
|
|
152
|
+
|
|
48
153
|
public function register(array $options = []): OpenIDConnectClient
|
|
49
154
|
{
|
|
50
155
|
// Get registration options
|
|
@@ -67,11 +172,26 @@ final class OpenIDConnectClient extends BaseOpenIDConnectClient
|
|
|
67
172
|
$registrationUrl = $openIdConfig->registration_endpoint;
|
|
68
173
|
|
|
69
174
|
// Request registration for Client which should handle authentication
|
|
70
|
-
|
|
175
|
+
// Include scope in registration to ensure client is allowed to request these scopes
|
|
176
|
+
$registrationResponse = $this->solid->post($registrationUrl, [
|
|
177
|
+
'client_name' => $clientName,
|
|
178
|
+
'redirect_uris' => [$redirectUri],
|
|
179
|
+
'scope' => 'openid webid offline_access',
|
|
180
|
+
...$requestParams
|
|
181
|
+
], $requestOptions);
|
|
71
182
|
if ($registrationResponse->successful()) {
|
|
72
183
|
$clientCredentials = (object) $registrationResponse->json();
|
|
73
184
|
$this->setClientCredentials($clientName, $clientCredentials, $saveCredentials, $withCredentials);
|
|
74
185
|
} else {
|
|
186
|
+
Log::error('[CLIENT REGISTRATION FAILED]', [
|
|
187
|
+
'status' => $registrationResponse->status(),
|
|
188
|
+
'body' => $registrationResponse->body(),
|
|
189
|
+
'request_data' => [
|
|
190
|
+
'client_name' => $clientName,
|
|
191
|
+
'redirect_uris' => [$redirectUri],
|
|
192
|
+
'scope' => 'openid webid offline_access',
|
|
193
|
+
],
|
|
194
|
+
]);
|
|
75
195
|
throw new OpenIDConnectClientException('Error registering: Please contact the OpenID Connect provider and obtain a Client ID and Secret directly from them');
|
|
76
196
|
}
|
|
77
197
|
|
|
@@ -83,9 +203,90 @@ final class OpenIDConnectClient extends BaseOpenIDConnectClient
|
|
|
83
203
|
$this->setCodeChallengeMethod('S256');
|
|
84
204
|
$this->addScope(['openid', 'webid', 'offline_access']);
|
|
85
205
|
|
|
206
|
+
Log::info('[AUTHENTICATE]', [
|
|
207
|
+
'scopes' => $this->getScopes(),
|
|
208
|
+
'note' => 'Scopes will be included in authorization redirect',
|
|
209
|
+
]);
|
|
210
|
+
|
|
86
211
|
return parent::authenticate();
|
|
87
212
|
}
|
|
88
213
|
|
|
214
|
+
/**
|
|
215
|
+
* Override requestTokens to inject DPoP header for token endpoint.
|
|
216
|
+
*
|
|
217
|
+
* Note: Per OIDC spec (and Inrupt implementation), scope is sent in the authorization
|
|
218
|
+
* request, NOT in the token request. CSS should remember the granted scope.
|
|
219
|
+
*/
|
|
220
|
+
protected function requestTokens(string $code, array $headers = [])
|
|
221
|
+
{
|
|
222
|
+
$tokenEndpoint = $this->getProviderConfigValue('token_endpoint');
|
|
223
|
+
$tokenEndpointAuthMethodsSupported = $this->getProviderConfigValue('token_endpoint_auth_methods_supported', ['client_secret_basic']);
|
|
224
|
+
|
|
225
|
+
// Create DPoP proof for the token endpoint
|
|
226
|
+
$dpop = $this->createDPoP('POST', $tokenEndpoint, null);
|
|
227
|
+
$headers[] = 'DPoP: ' . $dpop;
|
|
228
|
+
|
|
229
|
+
// Build token request parameters (scope NOT included per OIDC spec - it's in authorization request)
|
|
230
|
+
$tokenParams = [
|
|
231
|
+
'grant_type' => 'authorization_code',
|
|
232
|
+
'code' => $code,
|
|
233
|
+
'redirect_uri' => $this->getRedirectURL(),
|
|
234
|
+
'client_id' => $this->getClientID(),
|
|
235
|
+
'client_secret' => $this->getClientSecret(),
|
|
236
|
+
];
|
|
237
|
+
|
|
238
|
+
Log::info('[REQUEST TOKENS WITH DPOP]', [
|
|
239
|
+
'token_endpoint' => $tokenEndpoint,
|
|
240
|
+
'expected_scope' => implode(' ', $this->getScopes()),
|
|
241
|
+
'dpop_length' => strlen($dpop),
|
|
242
|
+
'note' => 'Scope sent in authorization request, not token request per OIDC spec',
|
|
243
|
+
]);
|
|
244
|
+
|
|
245
|
+
// Handle different authentication methods
|
|
246
|
+
$authorizationHeader = null;
|
|
247
|
+
if ($this->supportsAuthMethod('client_secret_basic', $tokenEndpointAuthMethodsSupported)) {
|
|
248
|
+
$authorizationHeader = 'Authorization: Basic ' . base64_encode(urlencode($this->getClientID()) . ':' . urlencode($this->getClientSecret()));
|
|
249
|
+
unset($tokenParams['client_secret'], $tokenParams['client_id']);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// Add PKCE code verifier if using PKCE
|
|
253
|
+
$ccm = $this->getCodeChallengeMethod();
|
|
254
|
+
$cv = $this->getCodeVerifier();
|
|
255
|
+
if (!empty($ccm) && !empty($cv)) {
|
|
256
|
+
$cs = $this->getClientSecret();
|
|
257
|
+
if (empty($cs)) {
|
|
258
|
+
$authorizationHeader = null;
|
|
259
|
+
unset($tokenParams['client_secret']);
|
|
260
|
+
}
|
|
261
|
+
$tokenParams = array_merge($tokenParams, [
|
|
262
|
+
'client_id' => $this->getClientID(),
|
|
263
|
+
'code_verifier' => $this->getCodeVerifier()
|
|
264
|
+
]);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// Convert token params to string format
|
|
268
|
+
$tokenParams = http_build_query($tokenParams, '', '&', $this->encType);
|
|
269
|
+
|
|
270
|
+
if (null !== $authorizationHeader) {
|
|
271
|
+
$headers[] = $authorizationHeader;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// Make the token request
|
|
275
|
+
$rawResponse = $this->fetchURL($tokenEndpoint, $tokenParams, $headers);
|
|
276
|
+
$this->tokenResponse = json_decode($rawResponse, false);
|
|
277
|
+
|
|
278
|
+
Log::info('[TOKEN RESPONSE FROM CSS]', [
|
|
279
|
+
'has_access_token' => isset($this->tokenResponse->access_token),
|
|
280
|
+
'has_id_token' => isset($this->tokenResponse->id_token),
|
|
281
|
+
'has_refresh_token' => isset($this->tokenResponse->refresh_token),
|
|
282
|
+
'token_type' => $this->tokenResponse->token_type ?? null,
|
|
283
|
+
'scope_in_response' => $this->tokenResponse->scope ?? null,
|
|
284
|
+
'raw_response_length' => strlen($rawResponse),
|
|
285
|
+
]);
|
|
286
|
+
|
|
287
|
+
return $this->tokenResponse;
|
|
288
|
+
}
|
|
289
|
+
|
|
89
290
|
private function setClientCredentials(string $clientName = CLIENT_NAME, $clientCredentials, bool $save = false, ?\Closure $callback = null): OpenIDConnectClient
|
|
90
291
|
{
|
|
91
292
|
$this->setClientID($clientCredentials->client_id);
|
|
@@ -173,23 +374,26 @@ final class OpenIDConnectClient extends BaseOpenIDConnectClient
|
|
|
173
374
|
return null;
|
|
174
375
|
}
|
|
175
376
|
|
|
176
|
-
protected function getSessionKey($key)
|
|
377
|
+
protected function getSessionKey(string $key)
|
|
177
378
|
{
|
|
178
|
-
|
|
179
|
-
|
|
379
|
+
$sessionKey = 'oidc:session:' . ($this->identity ? $this->identity->identifier : 'default') . ':' . Str::slug($key);
|
|
380
|
+
if (Redis::exists($sessionKey)) {
|
|
381
|
+
return $this->retrieve($sessionKey);
|
|
180
382
|
}
|
|
181
383
|
|
|
182
384
|
return false;
|
|
183
385
|
}
|
|
184
386
|
|
|
185
|
-
protected function setSessionKey($key, $value)
|
|
387
|
+
protected function setSessionKey(string $key, $value)
|
|
186
388
|
{
|
|
187
|
-
$
|
|
389
|
+
$sessionKey = 'oidc:session:' . ($this->identity ? $this->identity->identifier : 'default') . ':' . Str::slug($key);
|
|
390
|
+
$this->save($sessionKey, $value);
|
|
188
391
|
}
|
|
189
392
|
|
|
190
|
-
protected function unsetSessionKey($key)
|
|
393
|
+
protected function unsetSessionKey(string $key)
|
|
191
394
|
{
|
|
192
|
-
|
|
395
|
+
$sessionKey = 'oidc:session:' . ($this->identity ? $this->identity->identifier : 'default') . ':' . Str::slug($key);
|
|
396
|
+
Redis::del($sessionKey);
|
|
193
397
|
}
|
|
194
398
|
|
|
195
399
|
protected function getAllSessionKeysWithValues()
|
|
@@ -211,7 +415,474 @@ final class OpenIDConnectClient extends BaseOpenIDConnectClient
|
|
|
211
415
|
return $keys;
|
|
212
416
|
}
|
|
213
417
|
|
|
214
|
-
|
|
418
|
+
public function verifyJWTSignature(string $jwt): bool
|
|
419
|
+
{
|
|
420
|
+
$jwks = json_decode($this->fetchURL($this->getProviderConfigValue('jwks_uri')), true);
|
|
421
|
+
if (!is_array($jwks)) {
|
|
422
|
+
throw new OpenIDConnectClientException('Error decoding JSON from jwks_uri');
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
try {
|
|
426
|
+
JWT::decode($jwt, JWK::parseKeySet($jwks));
|
|
427
|
+
} catch (\Exception $e) {
|
|
428
|
+
throw new OpenIDConnectClientException('Error decoding JWT: ' . $e->getMessage(), $e->getCode(), $e);
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
return true;
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
/**
|
|
435
|
+
* Set the ID token.
|
|
436
|
+
*/
|
|
437
|
+
public function setIdToken($idToken)
|
|
438
|
+
{
|
|
439
|
+
$this->idToken = $idToken;
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
/**
|
|
443
|
+
* Public wrapper for decodeJWT.
|
|
444
|
+
*/
|
|
445
|
+
public function decodeJWTPublic(string $jwt, int $section = 0)
|
|
446
|
+
{
|
|
447
|
+
return $this->decodeJWT($jwt, $section);
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
// /**
|
|
451
|
+
// * Get WebID from ID token.
|
|
452
|
+
// */
|
|
453
|
+
// public function getWebIdFromIdToken(?string $idToken = null): ?string
|
|
215
454
|
// {
|
|
455
|
+
// $token = $idToken ?? $this->getIdToken();
|
|
456
|
+
|
|
457
|
+
// if (!$token) {
|
|
458
|
+
// return null;
|
|
459
|
+
// }
|
|
460
|
+
|
|
461
|
+
// try {
|
|
462
|
+
// $claims = $this->decodeJWT($token, 1);
|
|
463
|
+
|
|
464
|
+
// return $claims->sub ?? $claims->webid ?? null;
|
|
465
|
+
// } catch (\Exception $e) {
|
|
466
|
+
// return null;
|
|
467
|
+
// }
|
|
216
468
|
// }
|
|
469
|
+
|
|
470
|
+
/**
|
|
471
|
+
* Get WebID from ID token with enhanced error handling.
|
|
472
|
+
*/
|
|
473
|
+
public function getWebIdFromIdToken(string $idToken): ?string
|
|
474
|
+
{
|
|
475
|
+
$token = $idToken ?? $this->getIdToken();
|
|
476
|
+
if (!$token) {
|
|
477
|
+
return null;
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
try {
|
|
481
|
+
Log::info('[EXTRACTING WEBID FROM ID TOKEN]', [
|
|
482
|
+
'token_length' => strlen($idToken),
|
|
483
|
+
]);
|
|
484
|
+
|
|
485
|
+
$payload = $this->decodeJWT($idToken, 1);
|
|
486
|
+
$webId = $payload->webid ?? $payload->sub ?? null;
|
|
487
|
+
|
|
488
|
+
Log::info('[WEBID EXTRACTED]', [
|
|
489
|
+
'webid' => $webId,
|
|
490
|
+
'payload_keys' => array_keys((array) $payload),
|
|
491
|
+
]);
|
|
492
|
+
|
|
493
|
+
return $webId;
|
|
494
|
+
} catch (\Throwable $e) {
|
|
495
|
+
Log::error('[WEBID EXTRACTION ERROR]', [
|
|
496
|
+
'error' => $e->getMessage(),
|
|
497
|
+
]);
|
|
498
|
+
|
|
499
|
+
return null;
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
/**
|
|
504
|
+
* Get all claims from ID token.
|
|
505
|
+
*/
|
|
506
|
+
public function getIdTokenClaims(?string $idToken = null): ?object
|
|
507
|
+
{
|
|
508
|
+
$token = $idToken ?? $this->getIdToken();
|
|
509
|
+
|
|
510
|
+
if (!$token) {
|
|
511
|
+
return null;
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
try {
|
|
515
|
+
return $this->decodeJWT($token, 1);
|
|
516
|
+
} catch (\Exception $e) {
|
|
517
|
+
return null;
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
/**
|
|
522
|
+
* Create DPoP token with proper signing and debugging.
|
|
523
|
+
*/
|
|
524
|
+
public function createDPoP(string $method, string $url, ?string $accessToken = null): string
|
|
525
|
+
{
|
|
526
|
+
try {
|
|
527
|
+
Log::info('[CREATING DPOP TOKEN]', [
|
|
528
|
+
'method' => strtolower($method),
|
|
529
|
+
'url' => $url,
|
|
530
|
+
'has_access_token' => $accessToken !== null,
|
|
531
|
+
]);
|
|
532
|
+
|
|
533
|
+
// Load (or generate) keypair
|
|
534
|
+
$keyPair = $this->getDPoPKeyPair();
|
|
535
|
+
|
|
536
|
+
if (!$keyPair || empty($keyPair['private_key']) || empty($keyPair['public_jwk'])) {
|
|
537
|
+
throw new \Exception('DPoP key pair unavailable');
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
$header = [
|
|
541
|
+
'typ' => 'dpop+jwt',
|
|
542
|
+
'alg' => 'RS256',
|
|
543
|
+
'jwk' => $keyPair['public_jwk'],
|
|
544
|
+
];
|
|
545
|
+
|
|
546
|
+
$now = time();
|
|
547
|
+
$jti = bin2hex(random_bytes(16));
|
|
548
|
+
$htm = strtoupper($method);
|
|
549
|
+
$htu = $url;
|
|
550
|
+
|
|
551
|
+
$payload = [
|
|
552
|
+
'jti' => $jti,
|
|
553
|
+
'htm' => $htm,
|
|
554
|
+
'htu' => $htu,
|
|
555
|
+
'iat' => $now,
|
|
556
|
+
];
|
|
557
|
+
|
|
558
|
+
// Only include 'ath' when we are binding a proof *for a resource request* or a refresh that already has an access token.
|
|
559
|
+
if (!empty($accessToken)) {
|
|
560
|
+
$payload['ath'] = self::generateAccessTokenHash($accessToken);
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
// Sign (RS256) with our private key
|
|
564
|
+
$privateKey = openssl_pkey_get_private($keyPair['private_key']);
|
|
565
|
+
if (!$privateKey) {
|
|
566
|
+
throw new \Exception('Failed to load DPoP private key');
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
$encodedHeader = rtrim(strtr(base64_encode(json_encode($header)), '+/', '-_'), '=');
|
|
570
|
+
$encodedPayload = rtrim(strtr(base64_encode(json_encode($payload)), '+/', '-_'), '=');
|
|
571
|
+
$signingInput = $encodedHeader . '.' . $encodedPayload;
|
|
572
|
+
|
|
573
|
+
$signature = '';
|
|
574
|
+
if (!openssl_sign($signingInput, $signature, $privateKey, OPENSSL_ALGO_SHA256)) {
|
|
575
|
+
throw new \Exception('Failed to sign DPoP proof');
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
$encodedSignature = rtrim(strtr(base64_encode($signature), '+/', '-_'), '=');
|
|
579
|
+
$jwt = $encodedHeader . '.' . $encodedPayload . '.' . $encodedSignature;
|
|
580
|
+
|
|
581
|
+
Log::info('[DPOP TOKEN CREATED]', [
|
|
582
|
+
'token_length' => strlen($jwt),
|
|
583
|
+
'token_preview' => substr($jwt, 0, 80),
|
|
584
|
+
'has_placeholder'=> false,
|
|
585
|
+
]);
|
|
586
|
+
|
|
587
|
+
// Debug: Log full DPoP payload for diagnosis
|
|
588
|
+
Log::debug('[DPOP PAYLOAD]', [
|
|
589
|
+
'jti' => $jti,
|
|
590
|
+
'htm' => $htm,
|
|
591
|
+
'htu' => $htu,
|
|
592
|
+
'iat' => $now,
|
|
593
|
+
'ath' => $payload['ath'] ?? null,
|
|
594
|
+
'has_ath' => isset($payload['ath']),
|
|
595
|
+
]);
|
|
596
|
+
|
|
597
|
+
return $jwt;
|
|
598
|
+
} catch (\Throwable $e) {
|
|
599
|
+
Log::error('[DPOP ERROR]', ['error' => $e->getMessage()]);
|
|
600
|
+
throw $e;
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
/**
|
|
605
|
+
* Generate or load DPoP key pair.
|
|
606
|
+
*/
|
|
607
|
+
private function getDPoPKeyPair(): ?array
|
|
608
|
+
{
|
|
609
|
+
$cacheKey = $this->getDPoPCacheKey();
|
|
610
|
+
|
|
611
|
+
if (isset(self::$dpopKeyPairCache[$cacheKey])) {
|
|
612
|
+
return self::$dpopKeyPairCache[$cacheKey];
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
try {
|
|
616
|
+
// Try to load existing key pair
|
|
617
|
+
$keyPair = $this->loadDPoPKeyPair();
|
|
618
|
+
|
|
619
|
+
if (!$keyPair) {
|
|
620
|
+
// Generate new key pair
|
|
621
|
+
$keyPair = self::generateDPoPKeyPair();
|
|
622
|
+
|
|
623
|
+
if ($keyPair) {
|
|
624
|
+
$this->saveDPoPKeyPair($keyPair);
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
self::$dpopKeyPairCache[$cacheKey] = $keyPair;
|
|
629
|
+
|
|
630
|
+
Log::info('[DPOP KEY PAIR LOADED]', [
|
|
631
|
+
'has_private_key' => isset($keyPair['private_key']),
|
|
632
|
+
'has_public_jwk' => isset($keyPair['public_jwk']),
|
|
633
|
+
'identity_uuid' => $this->identity?->uuid,
|
|
634
|
+
]);
|
|
635
|
+
|
|
636
|
+
return $keyPair;
|
|
637
|
+
} catch (\Throwable $e) {
|
|
638
|
+
Log::error('[DPOP KEY PAIR ERROR]', [
|
|
639
|
+
'error' => $e->getMessage(),
|
|
640
|
+
]);
|
|
641
|
+
|
|
642
|
+
return null;
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
/**
|
|
647
|
+
* Generate new DPoP key pair.
|
|
648
|
+
*/
|
|
649
|
+
private static function generateDPoPKeyPair(): ?array
|
|
650
|
+
{
|
|
651
|
+
try {
|
|
652
|
+
// Generate RSA key pair
|
|
653
|
+
$config = [
|
|
654
|
+
'digest_alg' => 'sha256',
|
|
655
|
+
'private_key_bits' => 2048,
|
|
656
|
+
'private_key_type' => OPENSSL_KEYTYPE_RSA,
|
|
657
|
+
];
|
|
658
|
+
|
|
659
|
+
$resource = openssl_pkey_new($config);
|
|
660
|
+
|
|
661
|
+
if (!$resource) {
|
|
662
|
+
throw new \Exception('Failed to generate RSA key pair');
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
// Export private key
|
|
666
|
+
openssl_pkey_export($resource, $privateKey);
|
|
667
|
+
|
|
668
|
+
// Get public key details
|
|
669
|
+
$publicKeyDetails = openssl_pkey_get_details($resource);
|
|
670
|
+
$publicKey = $publicKeyDetails['key'];
|
|
671
|
+
|
|
672
|
+
// Create JWK for public key
|
|
673
|
+
$publicJwk = [
|
|
674
|
+
'kty' => 'RSA',
|
|
675
|
+
'n' => rtrim(strtr(base64_encode($publicKeyDetails['rsa']['n']), '+/', '-_'), '='),
|
|
676
|
+
'e' => rtrim(strtr(base64_encode($publicKeyDetails['rsa']['e']), '+/', '-_'), '='),
|
|
677
|
+
];
|
|
678
|
+
|
|
679
|
+
Log::info('[DPOP KEY PAIR GENERATED]', [
|
|
680
|
+
'private_key_length' => strlen($privateKey),
|
|
681
|
+
'public_key_length' => strlen($publicKey),
|
|
682
|
+
'jwk_keys' => array_keys($publicJwk),
|
|
683
|
+
]);
|
|
684
|
+
|
|
685
|
+
return [
|
|
686
|
+
'private_key' => $privateKey,
|
|
687
|
+
'public_key' => $publicKey,
|
|
688
|
+
'public_jwk' => $publicJwk,
|
|
689
|
+
];
|
|
690
|
+
} catch (\Throwable $e) {
|
|
691
|
+
Log::error('[DPOP KEY GENERATION ERROR]', [
|
|
692
|
+
'error' => $e->getMessage(),
|
|
693
|
+
]);
|
|
694
|
+
|
|
695
|
+
return null;
|
|
696
|
+
}
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
/**
|
|
700
|
+
* Load DPoP key pair from storage.
|
|
701
|
+
*/
|
|
702
|
+
private function loadDPoPKeyPair(): ?array
|
|
703
|
+
{
|
|
704
|
+
try {
|
|
705
|
+
$keyPath = $this->getDPoPKeyPath();
|
|
706
|
+
|
|
707
|
+
if (!Storage::exists($keyPath)) {
|
|
708
|
+
return null;
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
$keyData = json_decode(Storage::get($keyPath), true);
|
|
712
|
+
|
|
713
|
+
if (!$keyData || !isset($keyData['private_key'], $keyData['public_jwk'])) {
|
|
714
|
+
return null;
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
Log::info('[DPOP KEY PAIR LOADED FROM STORAGE]');
|
|
718
|
+
|
|
719
|
+
return $keyData;
|
|
720
|
+
} catch (\Throwable $e) {
|
|
721
|
+
Log::warning('[DPOP KEY LOAD ERROR]', [
|
|
722
|
+
'error' => $e->getMessage(),
|
|
723
|
+
]);
|
|
724
|
+
|
|
725
|
+
return null;
|
|
726
|
+
}
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
/**
|
|
730
|
+
* Save DPoP key pair to storage.
|
|
731
|
+
*/
|
|
732
|
+
private function saveDPoPKeyPair(array $keyPair): void
|
|
733
|
+
{
|
|
734
|
+
try {
|
|
735
|
+
$keyPath = $this->getDPoPKeyPath();
|
|
736
|
+
|
|
737
|
+
Storage::put($keyPath, json_encode([
|
|
738
|
+
'private_key' => $keyPair['private_key'],
|
|
739
|
+
'public_key' => $keyPair['public_key'],
|
|
740
|
+
'public_jwk' => $keyPair['public_jwk'],
|
|
741
|
+
'created_at' => now()->toISOString(),
|
|
742
|
+
]));
|
|
743
|
+
|
|
744
|
+
Log::info('[DPOP KEY PAIR SAVED TO STORAGE]');
|
|
745
|
+
} catch (\Throwable $e) {
|
|
746
|
+
Log::warning('[DPOP KEY SAVE ERROR]', [
|
|
747
|
+
'error' => $e->getMessage(),
|
|
748
|
+
]);
|
|
749
|
+
}
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
/**
|
|
753
|
+
* Generate JTI (JWT ID).
|
|
754
|
+
*/
|
|
755
|
+
private static function generateJti(): string
|
|
756
|
+
{
|
|
757
|
+
return bin2hex(random_bytes(16));
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
/**
|
|
761
|
+
* Generate access token hash for DPoP.
|
|
762
|
+
*/
|
|
763
|
+
private static function generateAccessTokenHash(string $accessToken): string
|
|
764
|
+
{
|
|
765
|
+
return rtrim(strtr(base64_encode(hash('sha256', $accessToken, true)), '+/', '-_'), '=');
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
/**
|
|
769
|
+
* Get DPoP key storage path for this identity.
|
|
770
|
+
*/
|
|
771
|
+
private function getDPoPKeyPath(): string
|
|
772
|
+
{
|
|
773
|
+
if ($this->identity instanceof SolidIdentity) {
|
|
774
|
+
return 'solid/dpop_keys_' . $this->identity->uuid . '.json';
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
// Fallback to global key for non-identity contexts
|
|
778
|
+
return 'solid/dpop_keys.json';
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
/**
|
|
782
|
+
* Get DPoP cache key for this identity.
|
|
783
|
+
*/
|
|
784
|
+
private function getDPoPCacheKey(): string
|
|
785
|
+
{
|
|
786
|
+
if ($this->identity instanceof SolidIdentity) {
|
|
787
|
+
return 'identity_' . $this->identity->uuid;
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
return 'global';
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
/**
|
|
794
|
+
* Clear client credentials and DPoP keys.
|
|
795
|
+
*/
|
|
796
|
+
public function clearClientCredentials(): void
|
|
797
|
+
{
|
|
798
|
+
try {
|
|
799
|
+
// Clear stored DPoP keys for this identity
|
|
800
|
+
$keyPath = $this->getDPoPKeyPath();
|
|
801
|
+
if (Storage::exists($keyPath)) {
|
|
802
|
+
Storage::delete($keyPath);
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
$cacheKey = $this->getDPoPCacheKey();
|
|
806
|
+
unset(self::$dpopKeyPairCache[$cacheKey]);
|
|
807
|
+
|
|
808
|
+
Log::info('[CLIENT CREDENTIALS CLEARED]');
|
|
809
|
+
} catch (\Throwable $e) {
|
|
810
|
+
Log::warning('[CLEAR CREDENTIALS ERROR]', [
|
|
811
|
+
'error' => $e->getMessage(),
|
|
812
|
+
]);
|
|
813
|
+
}
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
/**
|
|
817
|
+
* Exchange authorization code for tokens with enhanced error handling.
|
|
818
|
+
*/
|
|
819
|
+
public function exchangeCodeForTokens(string $code, ?string $state = null): \stdClass
|
|
820
|
+
{
|
|
821
|
+
try {
|
|
822
|
+
Log::info('[EXCHANGING CODE FOR TOKENS]', [
|
|
823
|
+
'code_length' => strlen($code),
|
|
824
|
+
'state' => $state,
|
|
825
|
+
]);
|
|
826
|
+
|
|
827
|
+
$this->setCode($code);
|
|
828
|
+
if ($state !== null) {
|
|
829
|
+
$this->setState($state);
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
// Note: Scope is sent in authorization request, not token request (per OIDC spec)
|
|
833
|
+
// CSS should remember the granted scope from the authorization
|
|
834
|
+
$tokenResponse = $this->requestTokens($code);
|
|
835
|
+
|
|
836
|
+
Log::info('[TOKEN EXCHANGE SUCCESS]', [
|
|
837
|
+
'has_access_token' => isset($tokenResponse->access_token),
|
|
838
|
+
'has_id_token' => isset($tokenResponse->id_token),
|
|
839
|
+
'token_type' => $tokenResponse->token_type ?? 'unknown',
|
|
840
|
+
]);
|
|
841
|
+
|
|
842
|
+
return (object) $tokenResponse;
|
|
843
|
+
} catch (\Throwable $e) {
|
|
844
|
+
Log::error('[TOKEN EXCHANGE ERROR]', [
|
|
845
|
+
'error' => $e->getMessage(),
|
|
846
|
+
'trace' => $e->getTraceAsString(),
|
|
847
|
+
]);
|
|
848
|
+
throw $e;
|
|
849
|
+
}
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
public function setCode(string $code): self
|
|
853
|
+
{
|
|
854
|
+
$this->code = $code;
|
|
855
|
+
|
|
856
|
+
return $this;
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
private function sessionKey(string $key): string
|
|
860
|
+
{
|
|
861
|
+
$prefix = 'oidc:session:' . ($this->identity ? $this->identity->identifier : 'default') . ':';
|
|
862
|
+
|
|
863
|
+
return $prefix . Str::slug($key);
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
// Full implementations – no placeholders
|
|
867
|
+
protected function loadFromStorage(string $key): ?string
|
|
868
|
+
{
|
|
869
|
+
try {
|
|
870
|
+
$value = Redis::get($this->sessionKey($key));
|
|
871
|
+
|
|
872
|
+
return $value === null ? null : (string) $value;
|
|
873
|
+
} catch (\Throwable $e) {
|
|
874
|
+
Log::warning('[OIDC STORAGE LOAD ERROR]', ['key' => $key, 'error' => $e->getMessage()]);
|
|
875
|
+
|
|
876
|
+
return null;
|
|
877
|
+
}
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
protected function saveToStorage(string $key, string $value): void
|
|
881
|
+
{
|
|
882
|
+
try {
|
|
883
|
+
Redis::set($this->sessionKey($key), $value);
|
|
884
|
+
} catch (\Throwable $e) {
|
|
885
|
+
Log::warning('[OIDC STORAGE SAVE ERROR]', ['key' => $key, 'error' => $e->getMessage()]);
|
|
886
|
+
}
|
|
887
|
+
}
|
|
217
888
|
}
|