@fleetbase/solid-engine 0.0.4 → 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.
Files changed (61) hide show
  1. package/ACL_SOLUTION.md +72 -0
  2. package/CSS_SCOPE_ISSUE.md +140 -0
  3. package/HOTFIX_SYNTAX_ERROR.md +100 -0
  4. package/MANUAL_ACL_SETUP.md +135 -0
  5. package/REFACTORING_SUMMARY.md +330 -0
  6. package/VERIFICATION_CHECKLIST.md +82 -0
  7. package/addon/components/modals/create-solid-folder.hbs +29 -0
  8. package/addon/components/modals/import-solid-resources.hbs +85 -0
  9. package/addon/controllers/data/content.js +17 -0
  10. package/addon/controllers/data/index.js +219 -0
  11. package/addon/controllers/home.js +84 -0
  12. package/addon/engine.js +1 -24
  13. package/addon/extension.js +26 -0
  14. package/addon/routes/data/content.js +11 -0
  15. package/addon/routes/data/index.js +17 -0
  16. package/addon/routes.js +2 -7
  17. package/addon/styles/solid-engine.css +1 -2
  18. package/addon/templates/account.hbs +3 -3
  19. package/addon/templates/application.hbs +2 -12
  20. package/addon/templates/data/content.hbs +48 -0
  21. package/addon/templates/{pods/explorer.hbs → data/index.hbs} +6 -5
  22. package/addon/templates/home.hbs +168 -10
  23. package/app/components/modals/{backup-pod.js → create-solid-folder.js} +1 -1
  24. package/app/components/modals/{resync-pod.js → import-solid-resources.js} +1 -1
  25. package/app/components/modals/{create-pod.js → setup-css-credentials.js} +1 -1
  26. package/composer.json +4 -10
  27. package/extension.json +1 -1
  28. package/index.js +0 -11
  29. package/package.json +8 -8
  30. package/server/migrations/2024_12_21_add_css_credentials_to_solid_identities_table.php +32 -0
  31. package/server/src/Client/OpenIDConnectClient.php +686 -15
  32. package/server/src/Client/SolidClient.php +104 -8
  33. package/server/src/Http/Controllers/DataController.php +261 -0
  34. package/server/src/Http/Controllers/OIDCController.php +42 -8
  35. package/server/src/Http/Controllers/SolidController.php +179 -85
  36. package/server/src/Models/SolidIdentity.php +13 -3
  37. package/server/src/Services/AclService.php +146 -0
  38. package/server/src/Services/PodService.php +863 -0
  39. package/server/src/Services/ResourceSyncService.php +336 -0
  40. package/server/src/Services/VehicleSyncService.php +289 -0
  41. package/server/src/Support/Utils.php +10 -0
  42. package/server/src/routes.php +25 -1
  43. package/addon/components/modals/backup-pod.hbs +0 -3
  44. package/addon/components/modals/create-pod.hbs +0 -5
  45. package/addon/components/modals/resync-pod.hbs +0 -3
  46. package/addon/controllers/pods/explorer/content.js +0 -12
  47. package/addon/controllers/pods/explorer.js +0 -149
  48. package/addon/controllers/pods/index/pod.js +0 -12
  49. package/addon/controllers/pods/index.js +0 -137
  50. package/addon/routes/pods/explorer/content.js +0 -10
  51. package/addon/routes/pods/explorer.js +0 -44
  52. package/addon/routes/pods/index/pod.js +0 -3
  53. package/addon/routes/pods/index.js +0 -21
  54. package/addon/templates/pods/explorer/content.hbs +0 -19
  55. package/addon/templates/pods/index/pod.hbs +0 -11
  56. package/addon/templates/pods/index.hbs +0 -19
  57. package/server/src/LegacyClient/Identity/IdentityProvider.php +0 -174
  58. package/server/src/LegacyClient/Identity/Profile.php +0 -18
  59. package/server/src/LegacyClient/OIDCClient.php +0 -350
  60. package/server/src/LegacyClient/Profile/WebID.php +0 -26
  61. 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
- $this->setClientID(data_get($options, 'clientID'));
29
- $this->setClientSecret(data_get($options, 'clientSecret'));
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
- $this->restoreClientCredentials();
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
- $registrationResponse = $this->solid->post($registrationUrl, ['client_name' => $clientName, 'redirect_uris' => [$redirectUri], ...$requestParams], $requestOptions);
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
- if (Redis::exists('oidc:session' . Str::slug($key))) {
179
- return $this->retrieve('oidc:session' . Str::slug($key));
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
- $this->save('oidc:session' . Str::slug($key), $value);
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
- Redis::del('oidc:session' . Str::slug($key));
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
- // public static function createDPoP(string $method, string $url, string $accessToken = null): string
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
  }