|
| 1 | +<?php |
| 2 | +/** |
| 3 | + * DDLess Passport Workaround |
| 4 | + * |
| 5 | + * When running under DDLess (PHP CLI), Laravel Passport's PersonalAccessTokenFactory |
| 6 | + * makes an HTTP request to /oauth/token using Guzzle. This fails because there's |
| 7 | + * no HTTP server running in the CLI process. |
| 8 | + * |
| 9 | + * This workaround replaces PersonalAccessTokenFactory with a version that |
| 10 | + * generates tokens directly without making HTTP requests. |
| 11 | + */ |
| 12 | + |
| 13 | +namespace DDLess\Workarounds; |
| 14 | + |
| 15 | +use Illuminate\Contracts\Foundation\Application; |
| 16 | + |
| 17 | +/** |
| 18 | + * Check if we should apply Passport workarounds |
| 19 | + */ |
| 20 | +function shouldApplyPassportWorkaround(): bool |
| 21 | +{ |
| 22 | + return php_sapi_name() === 'cli' || getenv('DDLESS_DEBUG_MODE') === 'true'; |
| 23 | +} |
| 24 | + |
| 25 | +/** |
| 26 | + * Register Passport workarounds with the Laravel application |
| 27 | + */ |
| 28 | +function registerPassportWorkaround(Application $app): void |
| 29 | +{ |
| 30 | + if (!shouldApplyPassportWorkaround()) { |
| 31 | + return; |
| 32 | + } |
| 33 | + |
| 34 | + // Check if Passport is installed |
| 35 | + if (!class_exists('Laravel\Passport\Passport')) { |
| 36 | + return; |
| 37 | + } |
| 38 | + |
| 39 | + fwrite(STDERR, "[ddless] Passport detected - replacing PersonalAccessTokenFactory...\n"); |
| 40 | + |
| 41 | + try { |
| 42 | + // Replace the PersonalAccessTokenFactory with our direct implementation |
| 43 | + $app->singleton(\Laravel\Passport\PersonalAccessTokenFactory::class, function ($app) { |
| 44 | + fwrite(STDERR, "[ddless] Creating DirectPersonalAccessTokenFactory...\n"); |
| 45 | + return new DirectPersonalAccessTokenFactory( |
| 46 | + $app->make(\Laravel\Passport\ClientRepository::class) |
| 47 | + ); |
| 48 | + }); |
| 49 | + |
| 50 | + fwrite(STDERR, "[ddless] Passport PersonalAccessTokenFactory replaced successfully.\n"); |
| 51 | + } catch (\Throwable $e) { |
| 52 | + fwrite(STDERR, "[ddless] Failed to replace PersonalAccessTokenFactory: " . $e->getMessage() . "\n"); |
| 53 | + fwrite(STDERR, "[ddless] Stack: " . $e->getTraceAsString() . "\n"); |
| 54 | + } |
| 55 | +} |
| 56 | + |
| 57 | +/** |
| 58 | + * Direct token factory that doesn't make HTTP requests |
| 59 | + * |
| 60 | + * This replaces Laravel\Passport\PersonalAccessTokenFactory |
| 61 | + */ |
| 62 | +class DirectPersonalAccessTokenFactory |
| 63 | +{ |
| 64 | + protected $clients; |
| 65 | + |
| 66 | + public function __construct(\Laravel\Passport\ClientRepository $clients) |
| 67 | + { |
| 68 | + $this->clients = $clients; |
| 69 | + } |
| 70 | + |
| 71 | + /** |
| 72 | + * Create a new personal access token. |
| 73 | + * |
| 74 | + * @param mixed $userId |
| 75 | + * @param string $name |
| 76 | + * @param array $scopes |
| 77 | + * @return \Laravel\Passport\PersonalAccessTokenResult |
| 78 | + */ |
| 79 | + public function make($userId, $name, array $scopes = []) |
| 80 | + { |
| 81 | + fwrite(STDERR, "[ddless] DirectPersonalAccessTokenFactory::make() for user {$userId}, name: {$name}\n"); |
| 82 | + |
| 83 | + $client = $this->clients->personalAccessClient(); |
| 84 | + |
| 85 | + if (!$client) { |
| 86 | + throw new \RuntimeException( |
| 87 | + '[ddless] Personal access client not found. Run: php artisan passport:client --personal' |
| 88 | + ); |
| 89 | + } |
| 90 | + |
| 91 | + // Generate token ID |
| 92 | + $tokenId = hash('sha256', bin2hex(random_bytes(40))); |
| 93 | + |
| 94 | + // Calculate expiration |
| 95 | + $expiresAt = now()->addYear(); |
| 96 | + if (class_exists('\Laravel\Passport\Passport')) { |
| 97 | + $expiration = \Laravel\Passport\Passport::personalAccessTokensExpireIn(); |
| 98 | + if ($expiration) { |
| 99 | + $expiresAt = now()->add($expiration); |
| 100 | + } |
| 101 | + } |
| 102 | + |
| 103 | + // Create the token record directly using Eloquent |
| 104 | + $tokenModel = config('passport.token_model', \Laravel\Passport\Token::class); |
| 105 | + $token = new $tokenModel(); |
| 106 | + $token->id = $tokenId; |
| 107 | + $token->user_id = $userId; |
| 108 | + $token->client_id = $client->getKey(); |
| 109 | + $token->name = $name; |
| 110 | + $token->scopes = $scopes; |
| 111 | + $token->revoked = false; |
| 112 | + $token->created_at = now(); |
| 113 | + $token->updated_at = now(); |
| 114 | + $token->expires_at = $expiresAt; |
| 115 | + $token->save(); |
| 116 | + |
| 117 | + // Generate the JWT access token |
| 118 | + $accessToken = $this->generateAccessToken($token, $client, $userId, $scopes, $expiresAt); |
| 119 | + |
| 120 | + fwrite(STDERR, "[ddless] Token created successfully with ID: " . substr($tokenId, 0, 8) . "...\n"); |
| 121 | + |
| 122 | + return new \Laravel\Passport\PersonalAccessTokenResult( |
| 123 | + $accessToken, |
| 124 | + $token |
| 125 | + ); |
| 126 | + } |
| 127 | + |
| 128 | + /** |
| 129 | + * Generate a JWT access token string |
| 130 | + */ |
| 131 | + protected function generateAccessToken($token, $client, $userId, array $scopes, $expiresAt): string |
| 132 | + { |
| 133 | + // Try to use Lcobucci JWT library (Passport's default) |
| 134 | + if (class_exists('\Lcobucci\JWT\Configuration')) { |
| 135 | + return $this->generateJwtToken($token, $client, $userId, $scopes, $expiresAt); |
| 136 | + } |
| 137 | + |
| 138 | + // Fallback: generate a simple bearer token |
| 139 | + fwrite(STDERR, "[ddless] JWT library not found, using simple token\n"); |
| 140 | + return $this->generateSimpleToken($token); |
| 141 | + } |
| 142 | + |
| 143 | + /** |
| 144 | + * Generate JWT token using Lcobucci library |
| 145 | + */ |
| 146 | + protected function generateJwtToken($token, $client, $userId, array $scopes, $expiresAt): string |
| 147 | + { |
| 148 | + try { |
| 149 | + $privateKeyPath = \Laravel\Passport\Passport::keyPath('oauth-private.key'); |
| 150 | + |
| 151 | + if (!file_exists($privateKeyPath)) { |
| 152 | + fwrite(STDERR, "[ddless] WARNING: Private key not found at {$privateKeyPath}\n"); |
| 153 | + fwrite(STDERR, "[ddless] Run: php artisan passport:keys\n"); |
| 154 | + return $this->generateSimpleToken($token); |
| 155 | + } |
| 156 | + |
| 157 | + $privateKey = file_get_contents($privateKeyPath); |
| 158 | + |
| 159 | + return $this->generateJwtV4($token, $client, $userId, $scopes, $expiresAt, $privateKey); |
| 160 | + } catch (\Throwable $e) { |
| 161 | + fwrite(STDERR, "[ddless] JWT generation failed: " . $e->getMessage() . "\n"); |
| 162 | + return $this->generateSimpleToken($token); |
| 163 | + } |
| 164 | + } |
| 165 | + |
| 166 | + /** |
| 167 | + * Generate JWT using Lcobucci 4.x/5.x |
| 168 | + */ |
| 169 | + protected function generateJwtV4($token, $client, $userId, array $scopes, $expiresAt, string $privateKey): string |
| 170 | + { |
| 171 | + $config = \Lcobucci\JWT\Configuration::forAsymmetricSigner( |
| 172 | + new \Lcobucci\JWT\Signer\Rsa\Sha256(), |
| 173 | + \Lcobucci\JWT\Signer\Key\InMemory::plainText($privateKey), |
| 174 | + \Lcobucci\JWT\Signer\Key\InMemory::plainText('') // Public key not needed for signing |
| 175 | + ); |
| 176 | + |
| 177 | + $now = new \DateTimeImmutable(); |
| 178 | + $expiresAtImmutable = $expiresAt instanceof \DateTimeImmutable |
| 179 | + ? $expiresAt |
| 180 | + : \DateTimeImmutable::createFromMutable( |
| 181 | + $expiresAt instanceof \DateTime ? $expiresAt : new \DateTime($expiresAt->format('Y-m-d H:i:s')) |
| 182 | + ); |
| 183 | + |
| 184 | + $jwtToken = $config->builder() |
| 185 | + ->issuedBy(config('app.url', 'http://localhost')) |
| 186 | + ->permittedFor((string) $client->getKey()) |
| 187 | + ->identifiedBy($token->id) |
| 188 | + ->issuedAt($now) |
| 189 | + ->canOnlyBeUsedAfter($now) |
| 190 | + ->expiresAt($expiresAtImmutable) |
| 191 | + ->relatedTo((string) $userId) |
| 192 | + ->withClaim('scopes', $scopes) |
| 193 | + ->getToken($config->signer(), $config->signingKey()); |
| 194 | + |
| 195 | + return $jwtToken->toString(); |
| 196 | + } |
| 197 | + |
| 198 | + /** |
| 199 | + * Generate a simple token (fallback when JWT is not available) |
| 200 | + */ |
| 201 | + protected function generateSimpleToken($token): string |
| 202 | + { |
| 203 | + // Create a simple encrypted token that can be used as bearer |
| 204 | + // This is not as secure as JWT but works for debugging purposes |
| 205 | + $payload = json_encode([ |
| 206 | + 'token_id' => $token->id, |
| 207 | + 'client_id' => $token->client_id, |
| 208 | + 'user_id' => $token->user_id, |
| 209 | + 'scopes' => $token->scopes, |
| 210 | + 'expires_at' => $token->expires_at->timestamp ?? time() + 31536000, |
| 211 | + 'random' => bin2hex(random_bytes(16)), |
| 212 | + ]); |
| 213 | + |
| 214 | + // Use Laravel's encryption if available |
| 215 | + if (function_exists('encrypt')) { |
| 216 | + try { |
| 217 | + return encrypt($payload); |
| 218 | + } catch (\Throwable $e) { |
| 219 | + // Fall through to base64 |
| 220 | + } |
| 221 | + } |
| 222 | + |
| 223 | + return base64_encode($payload); |
| 224 | + } |
| 225 | +} |
| 226 | + |
| 227 | +/** |
| 228 | + * Not used - kept for compatibility |
| 229 | + */ |
| 230 | +function registerPassportMiddleware(Application $app): void |
| 231 | +{ |
| 232 | + // Not needed with factory replacement approach |
| 233 | +} |
0 commit comments