Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
76 changes: 76 additions & 0 deletions app/Console/Commands/BootstrapPassport.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
<?php

declare(strict_types=1);

namespace App\Console\Commands;

use Database\Seeders\PassportSeeder;
use Illuminate\Console\Command;
use Illuminate\Database\ConnectionInterface;
use Illuminate\Support\Facades\DB;
use Laravel\Passport\ClientRepository;
use RuntimeException;
use Throwable;

class BootstrapPassport extends Command
{
private const MYSQL_LOCK_NAME = 'trypost.passport.personal-access-client';

private const PGSQL_LOCK_KEY = 684527671;

protected $signature = 'trypost:bootstrap-passport';

protected $description = 'Ensure the Passport personal access client exists.';

public function handle(PassportSeeder $seeder): int
{
try {
$this->withBootstrapLock(fn () => $seeder->run(app(ClientRepository::class)));
} catch (Throwable $exception) {
$this->error('Passport bootstrap failed: '.$exception->getMessage());

return self::FAILURE;
}

$this->info('Passport personal access client is ready.');

return self::SUCCESS;
}

private function withBootstrapLock(callable $callback): void
{
$connection = DB::connection();

match ($connection->getDriverName()) {
'pgsql' => $this->withPostgresLock($connection, $callback),
'mysql', 'mariadb' => $this->withMysqlLock($connection, $callback),
default => $callback(),
};
}

private function withPostgresLock(ConnectionInterface $connection, callable $callback): void
{
$connection->select('SELECT pg_advisory_lock(?)', [self::PGSQL_LOCK_KEY]);

try {
$callback();
} finally {
$connection->select('SELECT pg_advisory_unlock(?)', [self::PGSQL_LOCK_KEY]);
}
}

private function withMysqlLock(ConnectionInterface $connection, callable $callback): void
{
$result = $connection->selectOne('SELECT GET_LOCK(?, 30) AS lock_acquired', [self::MYSQL_LOCK_NAME]);

if ((int) ($result->lock_acquired ?? 0) !== 1) {
throw new RuntimeException('Could not acquire Passport bootstrap lock.');
}

try {
$callback();
} finally {
$connection->select('SELECT RELEASE_LOCK(?)', [self::MYSQL_LOCK_NAME]);
}
}
}
12 changes: 9 additions & 3 deletions docker/entrypoint.sh
Original file line number Diff line number Diff line change
Expand Up @@ -79,11 +79,17 @@ if [ ! -f storage/oauth-private.key ]; then
php artisan passport:keys --force || true
fi

# 10) Wayfinder TS regen — Vite needs the files before it boots.
# 10) Passport personal access client for API key creation.
if [ "${TARGET}" = "production" ]; then
echo "[entrypoint] bootstrapping Passport personal access client"
php artisan trypost:bootstrap-passport
fi

# 11) Wayfinder TS regen — Vite needs the files before it boots.
echo "[entrypoint] regenerating wayfinder helpers"
php artisan wayfinder:generate --with-form || true

# 11) Cache strategy: prod = pre-cache; dev = clear.
# 12) Cache strategy: prod = pre-cache; dev = clear.
if [ "${TARGET}" = "production" ]; then
php artisan config:cache
php artisan route:cache
Expand All @@ -96,7 +102,7 @@ else
php artisan event:clear
fi

# 12) Permissions. Production php-fpm pool runs as www-data (Alpine default),
# 13) Permissions. Production php-fpm pool runs as www-data (Alpine default),
# so storage and bootstrap/cache must be writable by that user — Laravel
# needs to write session files, view cache, log files, etc.
if [ "${TARGET}" = "production" ]; then
Expand Down
223 changes: 223 additions & 0 deletions tests/Feature/PassportBootstrapCommandTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,223 @@
<?php

declare(strict_types=1);

use App\Enums\UserWorkspace\Role;
use App\Models\AccessToken;
use App\Models\User;
use App\Models\Workspace;
use Database\Seeders\PassportSeeder;
use Illuminate\Support\Facades\Artisan;
use Laravel\Passport\Client;
use Laravel\Passport\ClientRepository;
use Laravel\Passport\Passport;

beforeEach(function () {
ensurePassportKeyPermissions();
});

function passportPersonalAccessClients()
{
return Client::query()
->where('revoked', false)
->where(function ($query): void {
$query->whereNull('provider')->orWhere('provider', 'users');
})
->get()
->filter(fn (Client $client): bool => $client->hasGrantType('personal_access'))
->values();
}

function ensurePassportKeyPermissions(): void
{
foreach ([storage_path('oauth-private.key'), storage_path('oauth-public.key')] as $path) {
if (is_file($path)) {
chmod($path, 0600);
}
}
}

function useTemporaryPassportKeys(): void
{
static $keyPath;

if (! $keyPath) {
$keyPath = sys_get_temp_dir().'/trypost-passport-test-keys';

if (! is_dir($keyPath)) {
mkdir($keyPath, 0700, true);
}

$privateKey = openssl_pkey_new([
'private_key_bits' => 2048,
'private_key_type' => OPENSSL_KEYTYPE_RSA,
]);

if ($privateKey === false) {
throw new RuntimeException('Could not generate temporary Passport private key.');
}

openssl_pkey_export($privateKey, $privateKeyContents);

$publicKeyDetails = openssl_pkey_get_details($privateKey);

if (! is_array($publicKeyDetails) || ! isset($publicKeyDetails['key'])) {
throw new RuntimeException('Could not generate temporary Passport public key.');
}

file_put_contents($keyPath.'/oauth-private.key', $privateKeyContents);
file_put_contents($keyPath.'/oauth-public.key', $publicKeyDetails['key']);
chmod($keyPath.'/oauth-private.key', 0600);
chmod($keyPath.'/oauth-public.key', 0600);
}

Passport::loadKeysFrom($keyPath);
}

it('creates the users personal access client when missing', function () {
Client::query()->delete();

expect(passportPersonalAccessClients())->toHaveCount(0);

$this->artisan('trypost:bootstrap-passport')
->expectsOutputToContain('Passport personal access client is ready.')
->assertSuccessful();

$clients = passportPersonalAccessClients();

expect($clients)->toHaveCount(1);
expect($clients->first()->revoked)->toBeFalse();
});

it('does not create duplicate personal access clients on repeated runs', function () {
$this->artisan('trypost:bootstrap-passport')->assertSuccessful();
$this->artisan('trypost:bootstrap-passport')->assertSuccessful();

expect(passportPersonalAccessClients())->toHaveCount(1);
});

it('does not revoke or replace an existing personal access client', function () {
$this->artisan('trypost:bootstrap-passport')->assertSuccessful();

$client = passportPersonalAccessClients()->first();

$this->artisan('trypost:bootstrap-passport')->assertSuccessful();

expect(passportPersonalAccessClients())->toHaveCount(1);
expect($client->refresh()->revoked)->toBeFalse();
expect(passportPersonalAccessClients()->first()->id)->toBe($client->id);
});

it('keeps existing tokens valid after repeated bootstrap runs', function () {
$this->artisan('trypost:bootstrap-passport')->assertSuccessful();
useTemporaryPassportKeys();

$user = User::factory()->create();
$workspace = Workspace::factory()->create([
'account_id' => $user->account_id,
'user_id' => $user->id,
]);
$workspace->members()->attach($user->id, ['role' => Role::Admin->value]);
$user->update(['current_workspace_id' => $workspace->id]);

$token = $user->createToken('Existing')->token;
AccessToken::find($token->id)
->forceFill(['workspace_id' => $workspace->id])
->saveQuietly();

$this->artisan('trypost:bootstrap-passport')->assertSuccessful();

$token = $token->refresh();

expect($token->revoked)->toBeFalse();
expect($token->client_id)->toBe(passportPersonalAccessClients()->first()->id);
});

it('allows creating api keys after passport bootstrap', function () {
$this->artisan('trypost:bootstrap-passport')->assertSuccessful();
useTemporaryPassportKeys();

$user = User::factory()->create();
$workspace = Workspace::factory()->create([
'account_id' => $user->account_id,
'user_id' => $user->id,
]);
$workspace->members()->attach($user->id, ['role' => Role::Admin->value]);
$user->update(['current_workspace_id' => $workspace->id]);

$this->actingAs($user)
->post(route('app.api-keys.store'), ['name' => 'My API Key'])
->assertRedirect();

$tokens = AccessToken::where('user_id', $user->id)
->where('workspace_id', $workspace->id)
->get();

expect($tokens)->toHaveCount(1);
expect($tokens->first()->revoked)->toBeFalse();
});

it('creates a new valid client when the only personal access client is revoked', function () {
Client::query()->delete();

$revoked = app(ClientRepository::class)->createPersonalAccessGrantClient('Revoked client', 'users');
$revoked->forceFill(['revoked' => true])->save();
$revokedSecret = $revoked->secret;

$this->artisan('trypost:bootstrap-passport')
->expectsOutputToContain('Passport personal access client is ready.')
->assertSuccessful();

$clients = passportPersonalAccessClients();

expect($clients)->toHaveCount(1);
expect($clients->first()->id)->not->toBe($revoked->id);
expect($revoked->refresh()->revoked)->toBeTrue();
expect($revoked->refresh()->secret)->toBe($revokedSecret);
});

it('returns a clear failure when passport bootstrap fails', function () {
$this->app->bind(PassportSeeder::class, fn () => new class extends PassportSeeder
{
public function run(ClientRepository $clients): void
{
throw new RuntimeException('simulated seeder failure');
}
});

$this->artisan('trypost:bootstrap-passport')
->expectsOutputToContain('Passport bootstrap failed: simulated seeder failure')
->assertFailed();
});

it('does not regenerate passport keys', function () {
$privateKey = storage_path('oauth-private.key');
$publicKey = storage_path('oauth-public.key');

file_put_contents($privateKey, 'existing-private-key');
file_put_contents($publicKey, 'existing-public-key');

$this->artisan('trypost:bootstrap-passport')->assertSuccessful();

expect(file_get_contents($privateKey))->toBe('existing-private-key');
expect(file_get_contents($publicKey))->toBe('existing-public-key');
});

it('registers the bootstrap command once', function () {
$commands = collect(Artisan::all())->keys()
->filter(fn (string $name): bool => $name === 'trypost:bootstrap-passport')
->values();

expect($commands)->toHaveCount(1);
});

it('does not print the client secret', function () {
Client::query()->delete();

Artisan::call('trypost:bootstrap-passport');

$output = Artisan::output();
$client = passportPersonalAccessClients()->sole();

expect($output)->not->toContain($client->secret);
});