stage 2 of oauth removal, switch to sanctum pat tokens completed, docs updated
This commit is contained in:
@@ -1,190 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources;
|
||||
|
||||
use App\Filament\Resources\OAuthClientResource\Pages;
|
||||
use App\Models\OAuthClient;
|
||||
use App\Models\RefreshToken;
|
||||
use BackedEnum;
|
||||
use Filament\Forms;
|
||||
use Filament\Forms\Form;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Resources\Resource;
|
||||
use Filament\Schemas\Schema;
|
||||
use Filament\Tables;
|
||||
use Filament\Tables\Table;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class OAuthClientResource extends Resource
|
||||
{
|
||||
protected static ?string $model = OAuthClient::class;
|
||||
|
||||
protected static BackedEnum|string|null $navigationIcon = 'heroicon-o-key';
|
||||
|
||||
protected static ?int $navigationSort = 30;
|
||||
|
||||
public static function getNavigationGroup(): string
|
||||
{
|
||||
return __('admin.nav.security');
|
||||
}
|
||||
|
||||
public static function form(Schema $form): Schema
|
||||
{
|
||||
return $form->schema([
|
||||
Forms\Components\TextInput::make('name')
|
||||
->label(__('admin.oauth.fields.name'))
|
||||
->required()
|
||||
->maxLength(255),
|
||||
Forms\Components\TextInput::make('client_id')
|
||||
->label(__('admin.oauth.fields.client_id'))
|
||||
->required()
|
||||
->unique(ignoreRecord: true)
|
||||
->maxLength(255),
|
||||
Forms\Components\TextInput::make('client_secret')
|
||||
->label(__('admin.oauth.fields.client_secret'))
|
||||
->password()
|
||||
->revealable()
|
||||
->helperText(__('admin.oauth.hints.client_secret'))
|
||||
->dehydrated(fn (?string $state): bool => filled($state))
|
||||
->dehydrateStateUsing(fn (?string $state): ?string => filled($state) ? Hash::make($state) : null),
|
||||
Forms\Components\Select::make('tenant_id')
|
||||
->label(__('admin.oauth.fields.tenant'))
|
||||
->relationship('tenant', 'name')
|
||||
->searchable()
|
||||
->preload()
|
||||
->nullable(),
|
||||
Forms\Components\Textarea::make('redirect_uris')
|
||||
->label(__('admin.oauth.fields.redirect_uris'))
|
||||
->rows(4)
|
||||
->helperText(__('admin.oauth.hints.redirect_uris'))
|
||||
->formatStateUsing(fn ($state): string => is_array($state) ? implode(PHP_EOL, $state) : (string) $state)
|
||||
->dehydrateStateUsing(function (?string $state): array {
|
||||
$entries = collect(preg_split('/\r\n|\r|\n/', (string) $state))
|
||||
->map(fn ($uri) => trim($uri))
|
||||
->filter();
|
||||
|
||||
return $entries->values()->all();
|
||||
})
|
||||
->required(),
|
||||
Forms\Components\TagsInput::make('scopes')
|
||||
->label(__('admin.oauth.fields.scopes'))
|
||||
->placeholder('tenant:read')
|
||||
->suggestions([
|
||||
'tenant:read',
|
||||
'tenant:write',
|
||||
'tenant:admin',
|
||||
])
|
||||
->separator(',')
|
||||
->required(),
|
||||
Forms\Components\Toggle::make('is_active')
|
||||
->label(__('admin.oauth.fields.is_active'))
|
||||
->default(true),
|
||||
Forms\Components\Textarea::make('description')
|
||||
->label(__('admin.oauth.fields.description'))
|
||||
->rows(3)
|
||||
->columnSpanFull(),
|
||||
])->columns(2);
|
||||
}
|
||||
|
||||
public static function table(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->columns([
|
||||
Tables\Columns\TextColumn::make('name')
|
||||
->label(__('admin.oauth.fields.name'))
|
||||
->searchable()
|
||||
->sortable(),
|
||||
Tables\Columns\TextColumn::make('client_id')
|
||||
->label(__('admin.oauth.fields.client_id'))
|
||||
->copyable()
|
||||
->sortable(),
|
||||
Tables\Columns\TextColumn::make('tenant.name')
|
||||
->label(__('admin.oauth.fields.tenant'))
|
||||
->toggleable(isToggledHiddenByDefault: true)
|
||||
->sortable(),
|
||||
Tables\Columns\IconColumn::make('is_active')
|
||||
->label(__('admin.oauth.fields.is_active'))
|
||||
->boolean()
|
||||
->color(fn (bool $state): string => $state ? 'success' : 'danger'),
|
||||
Tables\Columns\TextColumn::make('redirect_uris')
|
||||
->label(__('admin.oauth.fields.redirect_uris'))
|
||||
->formatStateUsing(fn ($state) => collect(Arr::wrap($state))->implode("\n"))
|
||||
->limit(50)
|
||||
->toggleable(),
|
||||
Tables\Columns\TagsColumn::make('scopes')
|
||||
->label(__('admin.oauth.fields.scopes'))
|
||||
->separator(', ')
|
||||
->limit(4),
|
||||
Tables\Columns\TextColumn::make('updated_at')
|
||||
->label(__('admin.oauth.fields.updated_at'))
|
||||
->dateTime()
|
||||
->sortable()
|
||||
->toggleable(isToggledHiddenByDefault: true),
|
||||
])
|
||||
->filters([
|
||||
Tables\Filters\TernaryFilter::make('is_active')
|
||||
->label(__('admin.oauth.filters.is_active'))
|
||||
->placeholder(__('admin.oauth.filters.any'))
|
||||
->trueLabel(__('admin.oauth.filters.active'))
|
||||
->falseLabel(__('admin.oauth.filters.inactive')),
|
||||
Tables\Filters\SelectFilter::make('tenant_id')
|
||||
->label(__('admin.oauth.fields.tenant'))
|
||||
->relationship('tenant', 'name')
|
||||
->searchable(),
|
||||
])
|
||||
->actions([
|
||||
Tables\Actions\ViewAction::make(),
|
||||
Tables\Actions\EditAction::make(),
|
||||
Tables\Actions\Action::make('regenerate_secret')
|
||||
->label(__('admin.oauth.actions.regenerate_secret'))
|
||||
->icon('heroicon-o-arrow-path')
|
||||
->requiresConfirmation()
|
||||
->action(function (OAuthClient $record): void {
|
||||
$plainSecret = Str::random(48);
|
||||
|
||||
$record->forceFill([
|
||||
'client_secret' => Hash::make($plainSecret),
|
||||
])->save();
|
||||
|
||||
Notification::make()
|
||||
->title(__('admin.oauth.notifications.secret_regenerated_title'))
|
||||
->body(__('admin.oauth.notifications.secret_regenerated_body', [
|
||||
'secret' => $plainSecret,
|
||||
]))
|
||||
->success()
|
||||
->send();
|
||||
}),
|
||||
Tables\Actions\DeleteAction::make()
|
||||
->before(function (OAuthClient $record): void {
|
||||
RefreshToken::query()
|
||||
->where('client_id', $record->client_id)
|
||||
->delete();
|
||||
}),
|
||||
])
|
||||
->bulkActions([
|
||||
Tables\Actions\BulkActionGroup::make([
|
||||
Tables\Actions\DeleteBulkAction::make()
|
||||
->before(function (Collection $records): void {
|
||||
$records->each(function (OAuthClient $record) {
|
||||
RefreshToken::query()
|
||||
->where('client_id', $record->client_id)
|
||||
->delete();
|
||||
});
|
||||
}),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
|
||||
public static function getPages(): array
|
||||
{
|
||||
return [
|
||||
'index' => Pages\ListOAuthClients::route('/'),
|
||||
'create' => Pages\CreateOAuthClient::route('/create'),
|
||||
'view' => Pages\ViewOAuthClient::route('/{record}'),
|
||||
'edit' => Pages\EditOAuthClient::route('/{record}/edit'),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\OAuthClientResource\Pages;
|
||||
|
||||
use App\Filament\Resources\OAuthClientResource;
|
||||
use Filament\Resources\Pages\CreateRecord;
|
||||
|
||||
class CreateOAuthClient extends CreateRecord
|
||||
{
|
||||
protected static string $resource = OAuthClientResource::class;
|
||||
|
||||
protected function getCreatedNotificationTitle(): ?string
|
||||
{
|
||||
return __('admin.oauth.notifications.created_title');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\OAuthClientResource\Pages;
|
||||
|
||||
use App\Filament\Resources\OAuthClientResource;
|
||||
use Filament\Actions;
|
||||
use Filament\Resources\Pages\EditRecord;
|
||||
|
||||
class EditOAuthClient extends EditRecord
|
||||
{
|
||||
protected static string $resource = OAuthClientResource::class;
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
Actions\DeleteAction::make(),
|
||||
];
|
||||
}
|
||||
|
||||
protected function getSavedNotificationTitle(): ?string
|
||||
{
|
||||
return __('admin.oauth.notifications.updated_title');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\OAuthClientResource\Pages;
|
||||
|
||||
use App\Filament\Resources\OAuthClientResource;
|
||||
use Filament\Resources\Pages\ListRecords;
|
||||
|
||||
class ListOAuthClients extends ListRecords
|
||||
{
|
||||
protected static string $resource = OAuthClientResource::class;
|
||||
}
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\OAuthClientResource\Pages;
|
||||
|
||||
use App\Filament\Resources\OAuthClientResource;
|
||||
use Filament\Resources\Pages\ViewRecord;
|
||||
|
||||
class ViewOAuthClient extends ViewRecord
|
||||
{
|
||||
protected static string $resource = OAuthClientResource::class;
|
||||
}
|
||||
|
||||
@@ -1,200 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources;
|
||||
|
||||
use App\Filament\Resources\RefreshTokenResource\Pages;
|
||||
use App\Filament\Resources\RefreshTokenResource\RelationManagers\AuditsRelationManager;
|
||||
use App\Models\RefreshToken;
|
||||
use BackedEnum;
|
||||
use Filament\Forms;
|
||||
use Filament\Resources\Resource;
|
||||
use Filament\Schemas\Schema;
|
||||
use Filament\Tables;
|
||||
use Filament\Tables\Actions\Action;
|
||||
use Filament\Tables\Table;
|
||||
use Filament\Tables\Filters\SelectFilter;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
|
||||
class RefreshTokenResource extends Resource
|
||||
{
|
||||
protected static ?string $model = RefreshToken::class;
|
||||
|
||||
protected static BackedEnum|string|null $navigationIcon = 'heroicon-o-shield-check';
|
||||
|
||||
protected static ?int $navigationSort = 32;
|
||||
|
||||
public static function getNavigationGroup(): string
|
||||
{
|
||||
return __('admin.nav.security');
|
||||
}
|
||||
|
||||
public static function getNavigationLabel(): string
|
||||
{
|
||||
return __('admin.refresh_tokens.menu');
|
||||
}
|
||||
|
||||
public static function getPluralLabel(): string
|
||||
{
|
||||
return __('admin.refresh_tokens.menu');
|
||||
}
|
||||
|
||||
public static function getModelLabel(): string
|
||||
{
|
||||
return __('admin.refresh_tokens.single');
|
||||
}
|
||||
|
||||
public static function form(Schema $form): Schema
|
||||
{
|
||||
return $form->schema([]);
|
||||
}
|
||||
|
||||
public static function table(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->defaultSort('created_at', 'desc')
|
||||
->columns([
|
||||
Tables\Columns\TextColumn::make('tenant.name')
|
||||
->label(__('admin.refresh_tokens.fields.tenant'))
|
||||
->searchable(),
|
||||
Tables\Columns\TextColumn::make('client_id')
|
||||
->label(__('admin.refresh_tokens.fields.client'))
|
||||
->copyable()
|
||||
->toggleable(),
|
||||
Tables\Columns\TextColumn::make('ip_address')
|
||||
->label(__('admin.refresh_tokens.fields.ip_address'))
|
||||
->toggleable(),
|
||||
Tables\Columns\TextColumn::make('user_agent')
|
||||
->label(__('admin.refresh_tokens.fields.user_agent'))
|
||||
->limit(40)
|
||||
->tooltip(fn (RefreshToken $record) => $record->user_agent)
|
||||
->toggleable(isToggledHiddenByDefault: true),
|
||||
Tables\Columns\TextColumn::make('created_at')
|
||||
->label(__('admin.refresh_tokens.fields.created_at'))
|
||||
->since()
|
||||
->sortable(),
|
||||
Tables\Columns\TextColumn::make('last_used_at')
|
||||
->label(__('admin.refresh_tokens.fields.last_used_at'))
|
||||
->since()
|
||||
->sortable()
|
||||
->toggleable(),
|
||||
Tables\Columns\TextColumn::make('expires_at')
|
||||
->label(__('admin.refresh_tokens.fields.expires_at'))
|
||||
->dateTime()
|
||||
->sortable()
|
||||
->toggleable(),
|
||||
Tables\Columns\TextColumn::make('status')
|
||||
->label(__('admin.refresh_tokens.fields.status'))
|
||||
->badge()
|
||||
->formatStateUsing(function (RefreshToken $record): string {
|
||||
if ($record->revoked_at) {
|
||||
return __('admin.refresh_tokens.status.revoked');
|
||||
}
|
||||
|
||||
if ($record->expires_at && $record->expires_at->isPast()) {
|
||||
return __('admin.refresh_tokens.status.expired');
|
||||
}
|
||||
|
||||
return __('admin.refresh_tokens.status.active');
|
||||
})
|
||||
->color(function (RefreshToken $record): string {
|
||||
if ($record->revoked_at) {
|
||||
return 'danger';
|
||||
}
|
||||
|
||||
if ($record->expires_at && $record->expires_at->isPast()) {
|
||||
return 'warning';
|
||||
}
|
||||
|
||||
return 'success';
|
||||
}),
|
||||
Tables\Columns\TextColumn::make('revoked_reason')
|
||||
->label(__('admin.refresh_tokens.fields.revoked_reason'))
|
||||
->formatStateUsing(function (?string $state): ?string {
|
||||
if (! $state) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$key = "admin.refresh_tokens.reasons.{$state}";
|
||||
$translated = __($key);
|
||||
|
||||
return $translated === $key ? $state : $translated;
|
||||
})
|
||||
->badge()
|
||||
->color('gray')
|
||||
->toggleable(),
|
||||
])
|
||||
->filters([
|
||||
SelectFilter::make('status')
|
||||
->label(__('admin.refresh_tokens.filters.status'))
|
||||
->options([
|
||||
'active' => __('admin.refresh_tokens.status.active'),
|
||||
'revoked' => __('admin.refresh_tokens.status.revoked'),
|
||||
'expired' => __('admin.refresh_tokens.status.expired'),
|
||||
])
|
||||
->query(function (Builder $query, array $data): Builder {
|
||||
return match ($data['value'] ?? null) {
|
||||
'revoked' => $query->whereNotNull('revoked_at'),
|
||||
'expired' => $query->whereNull('revoked_at')->whereNotNull('expires_at')->where('expires_at', '<=', now()),
|
||||
'active' => $query->whereNull('revoked_at')->where(function ($inner) {
|
||||
$inner->whereNull('expires_at')
|
||||
->orWhere('expires_at', '>', now());
|
||||
}),
|
||||
default => $query,
|
||||
};
|
||||
}),
|
||||
SelectFilter::make('tenant_id')
|
||||
->label(__('admin.refresh_tokens.filters.tenant'))
|
||||
->relationship('tenant', 'name')
|
||||
->searchable(),
|
||||
])
|
||||
->actions([
|
||||
Tables\Actions\ViewAction::make(),
|
||||
Action::make('revoke')
|
||||
->label(__('admin.refresh_tokens.actions.revoke'))
|
||||
->icon('heroicon-o-no-symbol')
|
||||
->color('danger')
|
||||
->visible(fn (RefreshToken $record): bool => $record->isActive())
|
||||
->form([
|
||||
Forms\Components\Select::make('reason')
|
||||
->label(__('admin.refresh_tokens.fields.revoked_reason'))
|
||||
->options([
|
||||
'manual' => __('admin.refresh_tokens.reasons.manual'),
|
||||
'operator' => __('admin.refresh_tokens.reasons.operator'),
|
||||
])
|
||||
->default('manual')
|
||||
->required(),
|
||||
Forms\Components\Textarea::make('note')
|
||||
->label(__('admin.refresh_tokens.fields.note'))
|
||||
->rows(2)
|
||||
->maxLength(255),
|
||||
])
|
||||
->requiresConfirmation()
|
||||
->action(function (RefreshToken $record, array $data): void {
|
||||
$note = $data['note'] ?? null;
|
||||
|
||||
$record->revoke(
|
||||
$data['reason'] ?? 'manual',
|
||||
auth()->id(),
|
||||
request(),
|
||||
$note ? ['note' => $note] : []
|
||||
);
|
||||
}),
|
||||
])
|
||||
->bulkActions([]);
|
||||
}
|
||||
|
||||
public static function getRelations(): array
|
||||
{
|
||||
return [
|
||||
AuditsRelationManager::class,
|
||||
];
|
||||
}
|
||||
|
||||
public static function getPages(): array
|
||||
{
|
||||
return [
|
||||
'index' => Pages\ListRefreshTokens::route('/'),
|
||||
'view' => Pages\ViewRefreshToken::route('/{record}'),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\RefreshTokenResource\Pages;
|
||||
|
||||
use App\Filament\Resources\RefreshTokenResource;
|
||||
use Filament\Resources\Pages\ListRecords;
|
||||
|
||||
class ListRefreshTokens extends ListRecords
|
||||
{
|
||||
protected static string $resource = RefreshTokenResource::class;
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\RefreshTokenResource\Pages;
|
||||
|
||||
use App\Filament\Resources\RefreshTokenResource;
|
||||
use Filament\Actions;
|
||||
use Filament\Forms;
|
||||
use Filament\Resources\Pages\ViewRecord;
|
||||
|
||||
class ViewRefreshToken extends ViewRecord
|
||||
{
|
||||
protected static string $resource = RefreshTokenResource::class;
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
Actions\Action::make('revoke')
|
||||
->label(__('admin.refresh_tokens.actions.revoke'))
|
||||
->icon('heroicon-o-no-symbol')
|
||||
->color('danger')
|
||||
->visible(fn (): bool => $this->record->isActive())
|
||||
->form([
|
||||
Forms\Components\Select::make('reason')
|
||||
->label(__('admin.refresh_tokens.fields.revoked_reason'))
|
||||
->options([
|
||||
'manual' => __('admin.refresh_tokens.reasons.manual'),
|
||||
'operator' => __('admin.refresh_tokens.reasons.operator'),
|
||||
])
|
||||
->default('manual')
|
||||
->required(),
|
||||
Forms\Components\Textarea::make('note')
|
||||
->label(__('admin.refresh_tokens.fields.note'))
|
||||
->rows(2)
|
||||
->maxLength(255),
|
||||
])
|
||||
->requiresConfirmation()
|
||||
->action(function (array $data): void {
|
||||
$note = $data['note'] ?? null;
|
||||
|
||||
$this->record->revoke(
|
||||
$data['reason'] ?? 'manual',
|
||||
auth()->id(),
|
||||
request(),
|
||||
$note ? ['note' => $note] : []
|
||||
);
|
||||
|
||||
$this->record->refresh();
|
||||
|
||||
$this->notify('success', __('admin.refresh_tokens.notifications.revoked'));
|
||||
}),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,69 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\RefreshTokenResource\RelationManagers;
|
||||
|
||||
use Filament\Resources\RelationManagers\RelationManager;
|
||||
use Filament\Tables;
|
||||
use Filament\Tables\Table;
|
||||
|
||||
class AuditsRelationManager extends RelationManager
|
||||
{
|
||||
protected static string $relationship = 'audits';
|
||||
|
||||
public function table(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->recordTitleAttribute('event')
|
||||
->defaultSort('created_at', 'desc')
|
||||
->columns([
|
||||
Tables\Columns\TextColumn::make('created_at')
|
||||
->label(__('admin.refresh_tokens.audit.performed_at'))
|
||||
->dateTime()
|
||||
->sortable(),
|
||||
Tables\Columns\TextColumn::make('event')
|
||||
->label(__('admin.refresh_tokens.audit.event'))
|
||||
->badge()
|
||||
->formatStateUsing(function (?string $state): ?string {
|
||||
if (! $state) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$key = "admin.refresh_tokens.audit.events.{$state}";
|
||||
$translated = __($key);
|
||||
|
||||
return $translated === $key ? $state : $translated;
|
||||
}),
|
||||
Tables\Columns\TextColumn::make('performedBy.name')
|
||||
->label(__('admin.refresh_tokens.audit.performed_by'))
|
||||
->placeholder('—')
|
||||
->toggleable(),
|
||||
Tables\Columns\TextColumn::make('ip_address')
|
||||
->label(__('admin.refresh_tokens.audit.ip_address'))
|
||||
->toggleable(),
|
||||
Tables\Columns\TextColumn::make('context')
|
||||
->label(__('admin.refresh_tokens.audit.context'))
|
||||
->formatStateUsing(function ($state): string {
|
||||
if (! is_array($state) || empty($state)) {
|
||||
return '—';
|
||||
}
|
||||
|
||||
return collect($state)
|
||||
->filter(fn ($value) => filled($value))
|
||||
->map(function ($value, $key) {
|
||||
if (is_array($value)) {
|
||||
$value = json_encode($value);
|
||||
}
|
||||
|
||||
return "{$key}: {$value}";
|
||||
})
|
||||
->implode(', ');
|
||||
})
|
||||
->wrap()
|
||||
->toggleable(),
|
||||
])
|
||||
->filters([])
|
||||
->paginated([10, 25, 50])
|
||||
->emptyStateHeading(__('admin.refresh_tokens.audit.empty.heading'))
|
||||
->emptyStateDescription(__('admin.refresh_tokens.audit.empty.description'));
|
||||
}
|
||||
}
|
||||
@@ -109,6 +109,49 @@ class TenantAdminTokenController extends Controller
|
||||
]);
|
||||
}
|
||||
|
||||
public function legacyTenantMe(Request $request): JsonResponse
|
||||
{
|
||||
/** @var Tenant|null $tenant */
|
||||
$tenant = $request->attributes->get('tenant')
|
||||
?? $request->user()?->tenant;
|
||||
|
||||
if (! $tenant) {
|
||||
return response()->json([
|
||||
'error' => 'tenant_not_found',
|
||||
'message' => 'Tenant context missing.',
|
||||
], 404);
|
||||
}
|
||||
|
||||
$tenant->loadMissing('activeResellerPackage');
|
||||
|
||||
$user = $request->user();
|
||||
$abilities = $user?->currentAccessToken()?->abilities ?? [];
|
||||
|
||||
$fullName = null;
|
||||
if ($user) {
|
||||
$first = trim((string) ($user->first_name ?? ''));
|
||||
$last = trim((string) ($user->last_name ?? ''));
|
||||
$fullName = trim($first.' '.$last) ?: null;
|
||||
}
|
||||
|
||||
$activePackage = $tenant->activeResellerPackage;
|
||||
|
||||
return response()->json([
|
||||
'id' => $tenant->id,
|
||||
'tenant_id' => $tenant->id,
|
||||
'name' => $tenant->name,
|
||||
'slug' => $tenant->slug,
|
||||
'email' => $tenant->contact_email,
|
||||
'fullName' => $fullName,
|
||||
'event_credits_balance' => $tenant->event_credits_balance,
|
||||
'active_reseller_package_id' => $activePackage?->id,
|
||||
'remaining_events' => $activePackage?->remaining_events ?? 0,
|
||||
'package_expires_at' => $activePackage?->expires_at,
|
||||
'features' => $tenant->features ?? [],
|
||||
'scopes' => $abilities,
|
||||
]);
|
||||
}
|
||||
|
||||
public function exchange(Request $request): JsonResponse
|
||||
{
|
||||
/** @var User|null $user */
|
||||
|
||||
@@ -209,7 +209,7 @@ class AuthenticatedSessionController extends Controller
|
||||
$path = '/'.$path;
|
||||
}
|
||||
|
||||
if (str_starts_with($path, '/event-admin') || str_starts_with($path, '/api/v1/oauth/authorize')) {
|
||||
if (str_starts_with($path, '/event-admin')) {
|
||||
return $hasScheme ? $target : $path;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class OAuthClient extends Model
|
||||
{
|
||||
protected $table = 'oauth_clients';
|
||||
|
||||
protected $guarded = [];
|
||||
|
||||
protected $fillable = [
|
||||
'id',
|
||||
'client_id',
|
||||
'client_secret',
|
||||
'tenant_id',
|
||||
'redirect_uris',
|
||||
'scopes',
|
||||
'is_active',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'id' => 'string',
|
||||
'tenant_id' => 'integer',
|
||||
'redirect_uris' => 'array',
|
||||
'scopes' => 'array',
|
||||
'is_active' => 'bool',
|
||||
'created_at' => 'datetime',
|
||||
'updated_at' => 'datetime',
|
||||
];
|
||||
|
||||
public function tenant(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Tenant::class);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class OAuthCode extends Model
|
||||
{
|
||||
protected $table = 'oauth_codes';
|
||||
|
||||
public $timestamps = false;
|
||||
|
||||
protected $guarded = [];
|
||||
|
||||
protected $fillable = [
|
||||
'id',
|
||||
'client_id',
|
||||
'user_id',
|
||||
'code',
|
||||
'code_challenge',
|
||||
'state',
|
||||
'redirect_uri',
|
||||
'scope',
|
||||
'expires_at',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'expires_at' => 'datetime',
|
||||
'created_at' => 'datetime',
|
||||
];
|
||||
|
||||
public function client(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(OAuthClient::class, 'client_id', 'client_id');
|
||||
}
|
||||
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
|
||||
public function isExpired(): bool
|
||||
{
|
||||
return $this->expires_at < now();
|
||||
}
|
||||
}
|
||||
@@ -1,120 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class RefreshToken extends Model
|
||||
{
|
||||
public $incrementing = false;
|
||||
protected $keyType = 'string';
|
||||
public $timestamps = false;
|
||||
|
||||
protected $table = 'refresh_tokens';
|
||||
|
||||
protected $guarded = [];
|
||||
|
||||
protected $fillable = [
|
||||
'id',
|
||||
'tenant_id',
|
||||
'client_id',
|
||||
'token',
|
||||
'access_token',
|
||||
'expires_at',
|
||||
'last_used_at',
|
||||
'scope',
|
||||
'ip_address',
|
||||
'user_agent',
|
||||
'revoked_at',
|
||||
'revoked_reason',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'expires_at' => 'datetime',
|
||||
'last_used_at' => 'datetime',
|
||||
'revoked_at' => 'datetime',
|
||||
'created_at' => 'datetime',
|
||||
];
|
||||
|
||||
public function tenant(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Tenant::class);
|
||||
}
|
||||
|
||||
public function audits(): HasMany
|
||||
{
|
||||
return $this->hasMany(RefreshTokenAudit::class);
|
||||
}
|
||||
|
||||
public function revoke(?string $reason = null, ?int $performedBy = null, ?Request $request = null, array $context = []): bool
|
||||
{
|
||||
$result = $this->update([
|
||||
'revoked_at' => now(),
|
||||
'revoked_reason' => $reason,
|
||||
]);
|
||||
|
||||
$event = match ($reason) {
|
||||
'rotated' => 'rotated',
|
||||
'ip_mismatch' => 'ip_mismatch',
|
||||
'expired' => 'expired',
|
||||
'invalid_secret' => 'invalid_secret',
|
||||
'tenant_missing' => 'tenant_missing',
|
||||
'max_active_limit' => 'max_active_limit',
|
||||
default => 'revoked',
|
||||
};
|
||||
|
||||
$this->recordAudit(
|
||||
$event,
|
||||
array_merge([
|
||||
'reason' => $reason,
|
||||
], $context),
|
||||
$performedBy,
|
||||
$request
|
||||
);
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
public function isActive(): bool
|
||||
{
|
||||
if ($this->revoked_at !== null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $this->expires_at === null || $this->expires_at->isFuture();
|
||||
}
|
||||
|
||||
public function scopeActive($query)
|
||||
{
|
||||
return $query
|
||||
->whereNull('revoked_at')
|
||||
->where(function ($inner) {
|
||||
$inner->whereNull('expires_at')
|
||||
->orWhere('expires_at', '>', now());
|
||||
});
|
||||
}
|
||||
|
||||
public function scopeForTenant($query, string $tenantId)
|
||||
{
|
||||
return $query->where('tenant_id', $tenantId);
|
||||
}
|
||||
|
||||
public function recordAudit(string $event, array $context = [], ?int $performedBy = null, ?Request $request = null): void
|
||||
{
|
||||
$request ??= request();
|
||||
|
||||
$this->audits()->create([
|
||||
'tenant_id' => $this->tenant_id,
|
||||
'client_id' => $this->client_id,
|
||||
'event' => $event,
|
||||
'context' => $context ?: null,
|
||||
'ip_address' => $request?->ip(),
|
||||
'user_agent' => $request?->userAgent(),
|
||||
'performed_by' => $performedBy,
|
||||
'created_at' => now(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class RefreshTokenAudit extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'refresh_token_id',
|
||||
'tenant_id',
|
||||
'client_id',
|
||||
'event',
|
||||
'context',
|
||||
'ip_address',
|
||||
'user_agent',
|
||||
'performed_by',
|
||||
'created_at',
|
||||
];
|
||||
|
||||
public $timestamps = false;
|
||||
|
||||
protected $casts = [
|
||||
'context' => 'array',
|
||||
'created_at' => 'datetime',
|
||||
];
|
||||
|
||||
public function refreshToken(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(RefreshToken::class);
|
||||
}
|
||||
|
||||
public function tenant(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Tenant::class);
|
||||
}
|
||||
|
||||
public function performedBy(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'performed_by');
|
||||
}
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class TenantToken extends Model
|
||||
{
|
||||
public $incrementing = false;
|
||||
protected $keyType = 'string';
|
||||
public $timestamps = false;
|
||||
|
||||
protected $table = 'tenant_tokens';
|
||||
|
||||
protected $guarded = [];
|
||||
|
||||
protected $fillable = [
|
||||
'id',
|
||||
'tenant_id',
|
||||
'jti',
|
||||
'token_type',
|
||||
'expires_at',
|
||||
'revoked_at',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'expires_at' => 'datetime',
|
||||
'revoked_at' => 'datetime',
|
||||
'created_at' => 'datetime',
|
||||
];
|
||||
|
||||
public function scopeActive($query)
|
||||
{
|
||||
return $query->whereNull('revoked_at')->where('expires_at', '>', now());
|
||||
}
|
||||
|
||||
public function scopeForTenant($query, string $tenantId)
|
||||
{
|
||||
return $query->where('tenant_id', $tenantId);
|
||||
}
|
||||
|
||||
public function revoke(): bool
|
||||
{
|
||||
return $this->update(['revoked_at' => now()]);
|
||||
}
|
||||
|
||||
public function isActive(): bool
|
||||
{
|
||||
return $this->revoked_at === null && $this->expires_at > now();
|
||||
}
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Policies;
|
||||
|
||||
use App\Models\OAuthClient;
|
||||
use App\Models\User;
|
||||
use Illuminate\Auth\Access\HandlesAuthorization;
|
||||
|
||||
class OAuthClientPolicy
|
||||
{
|
||||
use HandlesAuthorization;
|
||||
|
||||
public function viewAny(User $user): bool
|
||||
{
|
||||
return $user->role === 'super_admin';
|
||||
}
|
||||
|
||||
public function view(User $user, OAuthClient $oauthClient): bool
|
||||
{
|
||||
return $user->role === 'super_admin';
|
||||
}
|
||||
|
||||
public function create(User $user): bool
|
||||
{
|
||||
return $user->role === 'super_admin';
|
||||
}
|
||||
|
||||
public function update(User $user, OAuthClient $oauthClient): bool
|
||||
{
|
||||
return $user->role === 'super_admin';
|
||||
}
|
||||
|
||||
public function delete(User $user, OAuthClient $oauthClient): bool
|
||||
{
|
||||
return $user->role === 'super_admin';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -128,10 +128,6 @@ class AppServiceProvider extends ServiceProvider
|
||||
return Limit::perMinute(100)->by($key);
|
||||
});
|
||||
|
||||
RateLimiter::for('oauth', function (Request $request) {
|
||||
return Limit::perMinute(10)->by('oauth:'.($request->ip() ?? 'unknown'));
|
||||
});
|
||||
|
||||
RateLimiter::for('tenant-auth', function (Request $request) {
|
||||
return Limit::perMinute(20)->by('tenant-auth:'.($request->ip() ?? 'unknown'));
|
||||
});
|
||||
|
||||
@@ -2,11 +2,9 @@
|
||||
|
||||
namespace App\Providers;
|
||||
|
||||
use App\Models\OAuthClient;
|
||||
use App\Models\PurchaseHistory;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Policies\OAuthClientPolicy;
|
||||
use App\Policies\PurchaseHistoryPolicy;
|
||||
use App\Policies\TenantPolicy;
|
||||
use Illuminate\Foundation\Support\Providers\AuthServiceProvider as ServiceProvider;
|
||||
@@ -22,7 +20,6 @@ class AuthServiceProvider extends ServiceProvider
|
||||
protected $policies = [
|
||||
Tenant::class => TenantPolicy::class,
|
||||
PurchaseHistory::class => PurchaseHistoryPolicy::class,
|
||||
OAuthClient::class => OAuthClientPolicy::class,
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -37,4 +34,3 @@ class AuthServiceProvider extends ServiceProvider
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,6 @@ namespace App\Support;
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Eloquent\ModelNotFoundException;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Arr;
|
||||
|
||||
class TenantAuth
|
||||
{
|
||||
@@ -16,24 +15,17 @@ class TenantAuth
|
||||
*/
|
||||
public static function resolveAdminUser(Request $request): User
|
||||
{
|
||||
$decoded = (array) $request->attributes->get('decoded_token', []);
|
||||
$tenantId = $request->attributes->get('tenant_id')
|
||||
?? $request->input('tenant_id')
|
||||
?? Arr::get($decoded, 'tenant_id');
|
||||
?? $request->user()?->tenant_id;
|
||||
|
||||
if (! $tenantId) {
|
||||
throw (new ModelNotFoundException)->setModel(User::class);
|
||||
}
|
||||
|
||||
$userId = Arr::get($decoded, 'user_id');
|
||||
|
||||
if ($userId) {
|
||||
$user = User::query()
|
||||
->whereKey($userId)
|
||||
->where('tenant_id', $tenantId)
|
||||
->first();
|
||||
|
||||
if ($user) {
|
||||
$user = $request->user();
|
||||
if ($user && in_array($user->role, ['tenant_admin', 'admin', 'super_admin'], true)) {
|
||||
if ($user->role !== 'super_admin' || (int) $user->tenant_id === (int) $tenantId) {
|
||||
return $user;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,6 @@ use App\Http\Middleware\HandleAppearance;
|
||||
use App\Http\Middleware\HandleInertiaRequests;
|
||||
use App\Http\Middleware\SetLocaleFromUser;
|
||||
use App\Http\Middleware\TenantIsolation;
|
||||
use App\Http\Middleware\TenantTokenGuard;
|
||||
use Illuminate\Foundation\Application;
|
||||
use Illuminate\Foundation\Configuration\Exceptions;
|
||||
use Illuminate\Foundation\Configuration\Middleware;
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
'keys' => [
|
||||
'current_kid' => env('OAUTH_JWT_KID', 'fotospiel-jwt'),
|
||||
'storage_path' => env('OAUTH_KEY_STORE', storage_path('app/oauth-keys')),
|
||||
],
|
||||
'refresh_tokens' => [
|
||||
'enforce_ip_binding' => env('OAUTH_REFRESH_ENFORCE_IP', true),
|
||||
'allow_subnet_match' => env('OAUTH_REFRESH_ALLOW_SUBNET', false),
|
||||
'max_active_per_tenant' => env('OAUTH_REFRESH_MAX_ACTIVE', 5),
|
||||
'audit_retention_days' => env('OAUTH_REFRESH_AUDIT_RETENTION_DAYS', 90),
|
||||
],
|
||||
];
|
||||
@@ -63,27 +63,4 @@ return [
|
||||
'queue' => env('REVENUECAT_WEBHOOK_QUEUE', 'webhooks'),
|
||||
],
|
||||
|
||||
'oauth' => [
|
||||
'tenant_admin' => [
|
||||
'id' => env('VITE_OAUTH_CLIENT_ID', 'tenant-admin-app'),
|
||||
'redirects' => (function (): array {
|
||||
$redirects = [];
|
||||
|
||||
$devServer = env('VITE_DEV_SERVER_URL');
|
||||
$redirects[] = rtrim($devServer ?: 'http://localhost:5173', '/').'/event-admin/auth/callback';
|
||||
|
||||
$appUrl = env('APP_URL');
|
||||
if ($appUrl) {
|
||||
$redirects[] = rtrim($appUrl, '/').'/event-admin/auth/callback';
|
||||
} else {
|
||||
$redirects[] = 'http://localhost:8000/event-admin/auth/callback';
|
||||
}
|
||||
|
||||
$extra = array_filter(array_map('trim', explode(',', (string) env('TENANT_ADMIN_OAUTH_REDIRECTS', ''))));
|
||||
|
||||
return array_values(array_unique(array_filter(array_merge($redirects, $extra))));
|
||||
})(),
|
||||
],
|
||||
],
|
||||
|
||||
];
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('refresh_token_audits', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('refresh_token_id');
|
||||
$table->string('tenant_id')->index();
|
||||
$table->string('client_id')->nullable()->index();
|
||||
$table->string('event', 64)->index();
|
||||
$table->json('context')->nullable();
|
||||
$table->string('ip_address', 45)->nullable();
|
||||
$table->text('user_agent')->nullable();
|
||||
$table->foreignId('performed_by')->nullable()->constrained('users')->nullOnDelete();
|
||||
$table->timestamp('created_at')->useCurrent();
|
||||
|
||||
$table->foreign('refresh_token_id')
|
||||
->references('id')
|
||||
->on('refresh_tokens')
|
||||
->cascadeOnDelete();
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('refresh_token_audits');
|
||||
}
|
||||
};
|
||||
@@ -1,34 +0,0 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('refresh_tokens', function (Blueprint $table) {
|
||||
if (! Schema::hasColumn('refresh_tokens', 'last_used_at')) {
|
||||
$table->timestamp('last_used_at')->nullable()->after('expires_at');
|
||||
}
|
||||
|
||||
if (! Schema::hasColumn('refresh_tokens', 'revoked_reason')) {
|
||||
$table->string('revoked_reason', 64)->nullable()->after('revoked_at');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('refresh_tokens', function (Blueprint $table) {
|
||||
if (Schema::hasColumn('refresh_tokens', 'last_used_at')) {
|
||||
$table->dropColumn('last_used_at');
|
||||
}
|
||||
|
||||
if (Schema::hasColumn('refresh_tokens', 'revoked_reason')) {
|
||||
$table->dropColumn('revoked_reason');
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -33,7 +33,6 @@ class DatabaseSeeder extends Seeder
|
||||
SuperAdminSeeder::class,
|
||||
DemoTenantSeeder::class,
|
||||
DemoEventSeeder::class,
|
||||
OAuthClientSeeder::class,
|
||||
]);
|
||||
|
||||
if (app()->environment(['local', 'development', 'demo'])) {
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace Database\Seeders;
|
||||
|
||||
use App\Models\OAuthClient;
|
||||
use App\Models\Tenant;
|
||||
use Illuminate\Database\Seeder;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class OAuthClientSeeder extends Seeder
|
||||
{
|
||||
/**
|
||||
* Run the database seeds.
|
||||
*/
|
||||
public function run(): void
|
||||
{
|
||||
$serviceConfig = config('services.oauth.tenant_admin', []);
|
||||
|
||||
$clientId = $serviceConfig['id'] ?? 'tenant-admin-app';
|
||||
$tenantId = Tenant::where('slug', 'demo-tenant')->value('id')
|
||||
?? Tenant::query()->orderBy('id')->value('id');
|
||||
|
||||
$redirectUris = Arr::wrap($serviceConfig['redirects'] ?? []);
|
||||
if (empty($redirectUris)) {
|
||||
$redirectUris = [
|
||||
'http://localhost:5173/event-admin/auth/callback',
|
||||
'http://localhost:8000/event-admin/auth/callback',
|
||||
];
|
||||
}
|
||||
|
||||
$scopes = [
|
||||
'tenant:read',
|
||||
'tenant:write',
|
||||
];
|
||||
|
||||
$client = OAuthClient::firstOrNew(['client_id' => $clientId]);
|
||||
|
||||
if (!$client->exists) {
|
||||
$client->id = (string) Str::uuid();
|
||||
}
|
||||
|
||||
$client->fill([
|
||||
'client_secret' => null, // Public client, no secret needed for PKCE
|
||||
'tenant_id' => $tenantId,
|
||||
'redirect_uris' => $redirectUris,
|
||||
'scopes' => $scopes,
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
$client->save();
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,6 @@ namespace Database\Seeders;
|
||||
use App\Models\Event;
|
||||
use App\Models\EventPackage;
|
||||
use App\Models\EventType;
|
||||
use App\Models\OAuthClient;
|
||||
use App\Models\Package;
|
||||
use App\Models\PackagePurchase;
|
||||
use App\Models\Tenant;
|
||||
@@ -125,7 +124,6 @@ class DemoLifecycleSeeder extends Seeder
|
||||
]);
|
||||
|
||||
$this->createTenantAdmin($tenant, 'storycraft-owner@demo.fotospiel');
|
||||
$this->ensureOAuthClientForTenant($tenant, 'demo-tenant-admin-storycraft');
|
||||
}
|
||||
|
||||
private function seedActiveTenant(Package $standard, Package $premium, EventType $weddingType, EventType $corporateType): void
|
||||
@@ -139,12 +137,7 @@ class DemoLifecycleSeeder extends Seeder
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
OAuthClient::query()
|
||||
->where('client_id', config('services.oauth.tenant_admin.id', 'tenant-admin-app'))
|
||||
->update(['tenant_id' => $tenant->id]);
|
||||
|
||||
$this->createTenantAdmin($tenant, 'hello@lumen-moments.demo');
|
||||
$this->ensureOAuthClientForTenant($tenant, 'demo-tenant-admin-lumen');
|
||||
|
||||
$purchase = PackagePurchase::create([
|
||||
'tenant_id' => $tenant->id,
|
||||
@@ -210,7 +203,6 @@ class DemoLifecycleSeeder extends Seeder
|
||||
]);
|
||||
|
||||
$this->createTenantAdmin($tenant, 'team@viewfinder.demo');
|
||||
$this->ensureOAuthClientForTenant($tenant, 'demo-tenant-admin-viewfinder');
|
||||
|
||||
$tenantPackage = TenantPackage::create([
|
||||
'tenant_id' => $tenant->id,
|
||||
@@ -280,7 +272,6 @@ class DemoLifecycleSeeder extends Seeder
|
||||
]);
|
||||
|
||||
$this->createTenantAdmin($tenant, 'support@pixelco.demo', role: 'member');
|
||||
$this->ensureOAuthClientForTenant($tenant, 'demo-tenant-admin-pixel');
|
||||
}
|
||||
|
||||
private function createTenantAdmin(Tenant $tenant, string $email, string $role = 'tenant_admin'): User
|
||||
@@ -379,30 +370,4 @@ class DemoLifecycleSeeder extends Seeder
|
||||
];
|
||||
}
|
||||
|
||||
private function ensureOAuthClientForTenant(Tenant $tenant, string $clientId): void
|
||||
{
|
||||
$redirectUris = config('services.oauth.tenant_admin.redirects', []);
|
||||
if (empty($redirectUris)) {
|
||||
$redirectUris = [
|
||||
'http://localhost:5173/event-admin/auth/callback',
|
||||
url('/event-admin/auth/callback'),
|
||||
];
|
||||
}
|
||||
|
||||
$client = OAuthClient::firstOrNew(['client_id' => $clientId]);
|
||||
|
||||
if (! $client->exists) {
|
||||
$client->id = (string) Str::uuid();
|
||||
}
|
||||
|
||||
$client->fill([
|
||||
'client_secret' => null,
|
||||
'tenant_id' => $tenant->id,
|
||||
'redirect_uris' => $redirectUris,
|
||||
'scopes' => ['tenant:read', 'tenant:write'],
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
$client->save();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -303,110 +303,6 @@ return [
|
||||
'export_success' => 'Export abgeschlossen. :count Einträge exportiert.',
|
||||
],
|
||||
|
||||
'oauth' => [
|
||||
'fields' => [
|
||||
'name' => 'Name',
|
||||
'client_id' => 'Client-ID',
|
||||
'client_secret' => 'Client-Secret',
|
||||
'tenant' => 'Mandant',
|
||||
'redirect_uris' => 'Redirect-URIs',
|
||||
'scopes' => 'Scopes',
|
||||
'is_active' => 'Aktiv',
|
||||
'description' => 'Beschreibung',
|
||||
'updated_at' => 'Zuletzt geändert',
|
||||
],
|
||||
'hints' => [
|
||||
'client_secret' => 'Leer lassen, um das bestehende Secret zu behalten oder für PKCE-Clients ohne Secret.',
|
||||
'redirect_uris' => 'Eine URL pro Zeile. Die Callback-URL muss exakt übereinstimmen.',
|
||||
],
|
||||
'filters' => [
|
||||
'is_active' => 'Status',
|
||||
'any' => 'Alle',
|
||||
'active' => 'Aktiv',
|
||||
'inactive' => 'Inaktiv',
|
||||
],
|
||||
'actions' => [
|
||||
'regenerate_secret' => 'Secret neu generieren',
|
||||
],
|
||||
'notifications' => [
|
||||
'secret_regenerated_title' => 'Neues Secret erstellt',
|
||||
'secret_regenerated_body' => 'Speichere das neue Secret sicher: :secret',
|
||||
'created_title' => 'OAuth-Client erstellt',
|
||||
'updated_title' => 'OAuth-Client gespeichert',
|
||||
],
|
||||
],
|
||||
|
||||
'refresh_tokens' => [
|
||||
'menu' => 'Refresh Tokens',
|
||||
'single' => 'Refresh Token',
|
||||
'fields' => [
|
||||
'tenant' => 'Mandant',
|
||||
'client' => 'Client',
|
||||
'status' => 'Status',
|
||||
'revoked_reason' => 'Widerrufsgrund',
|
||||
'created_at' => 'Erstellt',
|
||||
'last_used_at' => 'Zuletzt verwendet',
|
||||
'expires_at' => 'Gültig bis',
|
||||
'ip_address' => 'IP-Adresse',
|
||||
'user_agent' => 'User Agent',
|
||||
'note' => 'Notiz',
|
||||
],
|
||||
'status' => [
|
||||
'active' => 'Aktiv',
|
||||
'revoked' => 'Widerrufen',
|
||||
'expired' => 'Abgelaufen',
|
||||
],
|
||||
'filters' => [
|
||||
'status' => 'Status',
|
||||
'tenant' => 'Mandant',
|
||||
],
|
||||
'actions' => [
|
||||
'revoke' => 'Token widerrufen',
|
||||
],
|
||||
'reasons' => [
|
||||
'manual' => 'Manuell',
|
||||
'operator' => 'Operator-Aktion',
|
||||
'rotated' => 'Automatisch rotiert',
|
||||
'ip_mismatch' => 'IP-Abweichung',
|
||||
'expired' => 'Abgelaufen',
|
||||
'invalid_secret' => 'Ungültiges Secret',
|
||||
'tenant_missing' => 'Mandant entfernt',
|
||||
'max_active_limit' => 'Maximale Anzahl überschritten',
|
||||
],
|
||||
'sections' => [
|
||||
'details' => 'Token-Details',
|
||||
'security' => 'Sicherheitskontext',
|
||||
],
|
||||
'audit' => [
|
||||
'heading' => 'Audit-Log',
|
||||
'event' => 'Ereignis',
|
||||
'events' => [
|
||||
'issued' => 'Ausgestellt',
|
||||
'refresh_attempt' => 'Refresh versucht',
|
||||
'refreshed' => 'Refresh erfolgreich',
|
||||
'client_mismatch' => 'Client stimmt nicht überein',
|
||||
'invalid_secret' => 'Ungültiges Secret',
|
||||
'ip_mismatch' => 'IP-Abweichung',
|
||||
'expired' => 'Abgelaufen',
|
||||
'revoked' => 'Widerrufen',
|
||||
'rotated' => 'Rotiert',
|
||||
'tenant_missing' => 'Mandant fehlt',
|
||||
'max_active_limit' => 'Begrenzung erreicht',
|
||||
],
|
||||
'performed_by' => 'Ausgeführt von',
|
||||
'ip_address' => 'IP-Adresse',
|
||||
'context' => 'Kontext',
|
||||
'performed_at' => 'Zeitpunkt',
|
||||
'empty' => [
|
||||
'heading' => 'Noch keine Einträge',
|
||||
'description' => 'Sobald das Token verwendet wird, erscheinen hier Einträge.',
|
||||
],
|
||||
],
|
||||
'notifications' => [
|
||||
'revoked' => 'Refresh Token wurde widerrufen.',
|
||||
],
|
||||
],
|
||||
|
||||
'shell' => [
|
||||
'tenant_admin_title' => 'Tenant‑Admin',
|
||||
],
|
||||
|
||||
@@ -289,110 +289,6 @@ return [
|
||||
'export_success' => 'Export ready. :count rows exported.',
|
||||
],
|
||||
|
||||
'oauth' => [
|
||||
'fields' => [
|
||||
'name' => 'Name',
|
||||
'client_id' => 'Client ID',
|
||||
'client_secret' => 'Client secret',
|
||||
'tenant' => 'Tenant',
|
||||
'redirect_uris' => 'Redirect URIs',
|
||||
'scopes' => 'Scopes',
|
||||
'is_active' => 'Active',
|
||||
'description' => 'Description',
|
||||
'updated_at' => 'Last updated',
|
||||
],
|
||||
'hints' => [
|
||||
'client_secret' => 'Leave blank to keep the current secret or for PKCE/public clients.',
|
||||
'redirect_uris' => 'One URL per line. Must exactly match the callback on the client.',
|
||||
],
|
||||
'filters' => [
|
||||
'is_active' => 'Status',
|
||||
'any' => 'All',
|
||||
'active' => 'Active',
|
||||
'inactive' => 'Inactive',
|
||||
],
|
||||
'actions' => [
|
||||
'regenerate_secret' => 'Regenerate secret',
|
||||
],
|
||||
'notifications' => [
|
||||
'secret_regenerated_title' => 'New secret generated',
|
||||
'secret_regenerated_body' => 'Store the new secret securely: :secret',
|
||||
'created_title' => 'OAuth client created',
|
||||
'updated_title' => 'OAuth client saved',
|
||||
],
|
||||
],
|
||||
|
||||
'refresh_tokens' => [
|
||||
'menu' => 'Refresh tokens',
|
||||
'single' => 'Refresh token',
|
||||
'fields' => [
|
||||
'tenant' => 'Tenant',
|
||||
'client' => 'Client',
|
||||
'status' => 'Status',
|
||||
'revoked_reason' => 'Revoked reason',
|
||||
'created_at' => 'Created',
|
||||
'last_used_at' => 'Last used',
|
||||
'expires_at' => 'Expires at',
|
||||
'ip_address' => 'IP address',
|
||||
'user_agent' => 'User agent',
|
||||
'note' => 'Operator note',
|
||||
],
|
||||
'status' => [
|
||||
'active' => 'Active',
|
||||
'revoked' => 'Revoked',
|
||||
'expired' => 'Expired',
|
||||
],
|
||||
'filters' => [
|
||||
'status' => 'Status',
|
||||
'tenant' => 'Tenant',
|
||||
],
|
||||
'actions' => [
|
||||
'revoke' => 'Revoke token',
|
||||
],
|
||||
'reasons' => [
|
||||
'manual' => 'Manual',
|
||||
'operator' => 'Operator action',
|
||||
'rotated' => 'Rotated (auto)',
|
||||
'ip_mismatch' => 'IP mismatch',
|
||||
'expired' => 'Expired',
|
||||
'invalid_secret' => 'Invalid secret attempt',
|
||||
'tenant_missing' => 'Tenant removed',
|
||||
'max_active_limit' => 'Exceeded active token limit',
|
||||
],
|
||||
'sections' => [
|
||||
'details' => 'Token details',
|
||||
'security' => 'Security context',
|
||||
],
|
||||
'audit' => [
|
||||
'heading' => 'Audit log',
|
||||
'event' => 'Event',
|
||||
'events' => [
|
||||
'issued' => 'Issued',
|
||||
'refresh_attempt' => 'Refresh attempted',
|
||||
'refreshed' => 'Refresh succeeded',
|
||||
'client_mismatch' => 'Client mismatch',
|
||||
'invalid_secret' => 'Invalid secret',
|
||||
'ip_mismatch' => 'IP mismatch',
|
||||
'expired' => 'Expired',
|
||||
'revoked' => 'Revoked',
|
||||
'rotated' => 'Rotated',
|
||||
'tenant_missing' => 'Tenant missing',
|
||||
'max_active_limit' => 'Pruned (active limit)',
|
||||
],
|
||||
'performed_by' => 'Actor',
|
||||
'ip_address' => 'IP address',
|
||||
'context' => 'Context',
|
||||
'performed_at' => 'Timestamp',
|
||||
'empty' => [
|
||||
'heading' => 'No audit entries yet',
|
||||
'description' => 'Token activity will appear here once it is used.',
|
||||
],
|
||||
],
|
||||
'notifications' => [
|
||||
'revoked' => 'Refresh token revoked.',
|
||||
],
|
||||
],
|
||||
|
||||
'shell' => [
|
||||
'tenant_admin_title' => 'Tenant Admin',
|
||||
],
|
||||
|
||||
@@ -19,7 +19,6 @@ use App\Http\Controllers\Api\Tenant\TenantAdminTokenController;
|
||||
use App\Http\Controllers\Api\Tenant\TenantFeedbackController;
|
||||
use App\Http\Controllers\Api\TenantBillingController;
|
||||
use App\Http\Controllers\Api\TenantPackageController;
|
||||
use App\Http\Controllers\OAuthController;
|
||||
use App\Http\Controllers\RevenueCatWebhookController;
|
||||
use App\Http\Controllers\Tenant\CreditController;
|
||||
use Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse;
|
||||
@@ -32,11 +31,6 @@ Route::prefix('v1')->name('api.v1.')->group(function () {
|
||||
->middleware('throttle:60,1')
|
||||
->name('webhooks.revenuecat');
|
||||
|
||||
Route::middleware([EncryptCookies::class, AddQueuedCookiesToResponse::class, StartSession::class, 'throttle:oauth'])->group(function () {
|
||||
Route::get('/oauth/authorize', [OAuthController::class, 'authorize'])->name('oauth.authorize');
|
||||
Route::post('/oauth/token', [OAuthController::class, 'token'])->name('oauth.token');
|
||||
});
|
||||
|
||||
Route::prefix('tenant-auth')->name('tenant-auth.')->group(function () {
|
||||
Route::post('/login', [TenantAdminTokenController::class, 'store'])
|
||||
->middleware('throttle:tenant-auth')
|
||||
@@ -84,7 +78,7 @@ Route::prefix('v1')->name('api.v1.')->group(function () {
|
||||
Route::put('profile', [ProfileController::class, 'update'])->name('tenant.profile.update');
|
||||
Route::get('onboarding', [OnboardingController::class, 'show'])->name('tenant.onboarding.show');
|
||||
Route::post('onboarding', [OnboardingController::class, 'store'])->name('tenant.onboarding.store');
|
||||
Route::get('me', [OAuthController::class, 'me'])->name('tenant.me');
|
||||
Route::get('me', [TenantAdminTokenController::class, 'legacyTenantMe'])->name('tenant.me');
|
||||
Route::get('dashboard', DashboardController::class)->name('tenant.dashboard');
|
||||
Route::get('event-types', EventTypeController::class)->name('tenant.event-types.index');
|
||||
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Api\Tenant;
|
||||
|
||||
use Tests\TestCase;
|
||||
|
||||
class TenantTokenGuardTest extends TestCase
|
||||
{
|
||||
public function test_missing_token_returns_structured_error(): void
|
||||
{
|
||||
$response = $this->getJson('/api/v1/tenant/events');
|
||||
|
||||
$response->assertStatus(401);
|
||||
$response->assertJson([
|
||||
'error' => [
|
||||
'code' => 'token_missing',
|
||||
'title' => 'Token Missing',
|
||||
'message' => 'Authentication token not provided.',
|
||||
],
|
||||
]);
|
||||
|
||||
$error = $response->json('error');
|
||||
$this->assertIsArray($error);
|
||||
$this->assertArrayNotHasKey('meta', $error);
|
||||
}
|
||||
}
|
||||
@@ -120,7 +120,7 @@ class LoginTest extends TestCase
|
||||
'email_verified_at' => now(),
|
||||
]);
|
||||
|
||||
$intended = 'http://localhost/api/v1/oauth/authorize?client_id=tenant-admin-app&response_type=code';
|
||||
$intended = 'http://localhost/event-admin/dashboard?from=intended-test';
|
||||
$returnTarget = '/event-admin/dashboard';
|
||||
$encodedReturn = rtrim(strtr(base64_encode($returnTarget), '+/', '-_'), '=');
|
||||
|
||||
|
||||
@@ -56,7 +56,7 @@ class TenantAdminGoogleControllerTest extends TestCase
|
||||
Socialite::shouldReceive('driver')->once()->with('google')->andReturn($driver);
|
||||
$driver->shouldReceive('user')->once()->andReturn($socialiteUser);
|
||||
|
||||
$targetUrl = 'http://localhost:8000/api/v1/oauth/authorize?foo=bar';
|
||||
$targetUrl = 'http://localhost:8000/event-admin/dashboard?foo=bar';
|
||||
$encodedReturn = rtrim(strtr(base64_encode($targetUrl), '+/', '-_'), '=');
|
||||
|
||||
$this->withSession([
|
||||
|
||||
@@ -27,6 +27,8 @@ class TenantProfileApiTest extends TestCase
|
||||
'password' => Hash::make('secret-password'),
|
||||
'email' => 'tenant@example.com',
|
||||
'name' => 'Max Mustermann',
|
||||
'first_name' => 'Max',
|
||||
'last_name' => 'Mustermann',
|
||||
]);
|
||||
|
||||
$login = $this->postJson('/api/v1/tenant-auth/login', [
|
||||
@@ -57,6 +59,34 @@ class TenantProfileApiTest extends TestCase
|
||||
$data = $me->json();
|
||||
$this->assertEquals('Max Mustermann', data_get($data, 'user.name'));
|
||||
$this->assertContains('tenant-admin', $data['abilities']);
|
||||
|
||||
$legacy = $this
|
||||
->withHeader('Authorization', 'Bearer '.$token)
|
||||
->getJson('/api/v1/tenant/me');
|
||||
|
||||
$legacy->assertOk();
|
||||
$legacy->assertJsonFragment([
|
||||
'id' => $tenant->id,
|
||||
'tenant_id' => $tenant->id,
|
||||
'name' => 'Test Tenant GmbH',
|
||||
'event_credits_balance' => 12,
|
||||
'fullName' => 'Max Mustermann',
|
||||
]);
|
||||
$legacy->assertJsonStructure([
|
||||
'id',
|
||||
'tenant_id',
|
||||
'name',
|
||||
'slug',
|
||||
'email',
|
||||
'fullName',
|
||||
'event_credits_balance',
|
||||
'active_reseller_package_id',
|
||||
'remaining_events',
|
||||
'package_expires_at',
|
||||
'features',
|
||||
'scopes',
|
||||
]);
|
||||
$this->assertContains('tenant-admin', $legacy->json('scopes'));
|
||||
}
|
||||
|
||||
public function test_me_requires_valid_token(): void
|
||||
|
||||
@@ -1,204 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\OAuth;
|
||||
|
||||
use App\Models\OAuthClient;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Str;
|
||||
use Tests\TestCase;
|
||||
|
||||
class AuthorizeTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_authorize_redirects_guests_to_login(): void
|
||||
{
|
||||
$tenant = Tenant::factory()->create();
|
||||
$client = $this->createClientForTenant($tenant);
|
||||
$query = $this->buildAuthorizeQuery($client);
|
||||
$fullUrl = url('/api/v1/oauth/authorize?'.http_build_query($query));
|
||||
|
||||
$response = $this->get('/api/v1/oauth/authorize?'.http_build_query($query));
|
||||
|
||||
$response->assertRedirect();
|
||||
$location = $response->headers->get('Location');
|
||||
$this->assertNotNull($location);
|
||||
|
||||
$this->assertStringStartsWith(route('tenant.admin.login'), $location);
|
||||
|
||||
$parsed = parse_url($location);
|
||||
$actualQuery = [];
|
||||
parse_str($parsed['query'] ?? '', $actualQuery);
|
||||
|
||||
$this->assertSame('login_required', $actualQuery['error'] ?? null);
|
||||
$this->assertSame('Please sign in to continue.', $actualQuery['error_description'] ?? null);
|
||||
$this->assertReturnToMatches($query, $actualQuery['return_to'] ?? null);
|
||||
|
||||
$this->assertIntendedUrlMatches($query);
|
||||
}
|
||||
|
||||
public function test_authorize_returns_json_payload_for_ajax_guests(): void
|
||||
{
|
||||
$tenant = Tenant::factory()->create();
|
||||
$client = $this->createClientForTenant($tenant);
|
||||
$query = $this->buildAuthorizeQuery($client);
|
||||
|
||||
$response = $this->withHeaders(['Accept' => 'application/json'])
|
||||
->get('/api/v1/oauth/authorize?'.http_build_query($query));
|
||||
|
||||
$response->assertStatus(401)
|
||||
->assertJson([
|
||||
'error' => 'login_required',
|
||||
'error_description' => 'Please sign in to continue.',
|
||||
]);
|
||||
|
||||
$this->assertIntendedUrlMatches($query);
|
||||
}
|
||||
|
||||
public function test_authorize_rejects_when_user_cannot_access_client_tenant(): void
|
||||
{
|
||||
$homeTenant = Tenant::factory()->create();
|
||||
$otherTenant = Tenant::factory()->create();
|
||||
$user = User::factory()->create([
|
||||
'tenant_id' => $homeTenant->id,
|
||||
'role' => 'tenant_admin',
|
||||
]);
|
||||
|
||||
$client = $this->createClientForTenant($otherTenant);
|
||||
|
||||
$this->actingAs($user);
|
||||
|
||||
$query = $this->buildAuthorizeQuery($client);
|
||||
$response = $this->get('/api/v1/oauth/authorize?'.http_build_query($query));
|
||||
|
||||
$response->assertRedirect();
|
||||
$location = $response->headers->get('Location');
|
||||
$this->assertNotNull($location);
|
||||
|
||||
$parsed = parse_url($location);
|
||||
$actualQuery = [];
|
||||
parse_str($parsed['query'] ?? '', $actualQuery);
|
||||
|
||||
$this->assertSame('tenant_mismatch', $actualQuery['error'] ?? null);
|
||||
$this->assertReturnToMatches($query, $actualQuery['return_to'] ?? null);
|
||||
}
|
||||
|
||||
public function test_authorize_redirects_with_error_when_client_unknown(): void
|
||||
{
|
||||
$tenant = Tenant::factory()->create();
|
||||
$this->actingAs(User::factory()->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'role' => 'tenant_admin',
|
||||
]));
|
||||
|
||||
$query = $this->buildAuthorizeQuery(new OAuthClient([
|
||||
'client_id' => 'missing-client',
|
||||
'redirect_uris' => ['http://localhost/callback'],
|
||||
'scopes' => ['tenant:read', 'tenant:write'],
|
||||
]));
|
||||
|
||||
$response = $this->get('/api/v1/oauth/authorize?'.http_build_query($query));
|
||||
|
||||
$response->assertRedirect();
|
||||
$location = $response->headers->get('Location');
|
||||
$this->assertNotNull($location);
|
||||
|
||||
$parsed = parse_url($location);
|
||||
$actualQuery = [];
|
||||
parse_str($parsed['query'] ?? '', $actualQuery);
|
||||
|
||||
$this->assertSame('invalid_client', $actualQuery['error'] ?? null);
|
||||
$this->assertReturnToMatches($query, $actualQuery['return_to'] ?? null);
|
||||
}
|
||||
|
||||
public function test_authorize_returns_json_error_for_tenant_mismatch_when_requested(): void
|
||||
{
|
||||
$homeTenant = Tenant::factory()->create();
|
||||
$otherTenant = Tenant::factory()->create();
|
||||
$user = User::factory()->create([
|
||||
'tenant_id' => $homeTenant->id,
|
||||
'role' => 'tenant_admin',
|
||||
]);
|
||||
|
||||
$client = $this->createClientForTenant($otherTenant);
|
||||
|
||||
$this->actingAs($user);
|
||||
|
||||
$query = $this->buildAuthorizeQuery($client);
|
||||
$response = $this->withHeaders(['Accept' => 'application/json'])
|
||||
->get('/api/v1/oauth/authorize?'.http_build_query($query));
|
||||
|
||||
$response->assertStatus(403)
|
||||
->assertJson([
|
||||
'error' => 'tenant_mismatch',
|
||||
]);
|
||||
}
|
||||
|
||||
private function createClientForTenant(Tenant $tenant): OAuthClient
|
||||
{
|
||||
return OAuthClient::create([
|
||||
'id' => (string) Str::uuid(),
|
||||
'client_id' => 'tenant-admin-app-'.$tenant->id,
|
||||
'tenant_id' => $tenant->id,
|
||||
'client_secret' => null,
|
||||
'redirect_uris' => ['http://localhost/callback'],
|
||||
'scopes' => ['tenant:read', 'tenant:write'],
|
||||
'is_active' => true,
|
||||
]);
|
||||
}
|
||||
|
||||
private function buildAuthorizeQuery(OAuthClient $client): array
|
||||
{
|
||||
return [
|
||||
'client_id' => $client->client_id,
|
||||
'redirect_uri' => 'http://localhost/callback',
|
||||
'response_type' => 'code',
|
||||
'scope' => 'tenant:read tenant:write',
|
||||
'state' => Str::random(10),
|
||||
'code_challenge' => rtrim(strtr(base64_encode(hash('sha256', Str::random(32), true)), '+/', '-_'), '='),
|
||||
'code_challenge_method' => 'S256',
|
||||
];
|
||||
}
|
||||
|
||||
private function assertIntendedUrlMatches(array $expectedQuery): void
|
||||
{
|
||||
$intended = session('url.intended');
|
||||
$this->assertNotNull($intended, 'Expected intended URL to be recorded in session.');
|
||||
|
||||
$parts = parse_url($intended);
|
||||
$this->assertSame('/api/v1/oauth/authorize', $parts['path'] ?? null);
|
||||
|
||||
$actualQuery = [];
|
||||
parse_str($parts['query'] ?? '', $actualQuery);
|
||||
|
||||
$this->assertEqualsCanonicalizing($expectedQuery, $actualQuery);
|
||||
}
|
||||
|
||||
private function decodeReturnTo(?string $value): ?string
|
||||
{
|
||||
if ($value === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$padded = str_pad($value, strlen($value) + ((4 - (strlen($value) % 4)) % 4), '=');
|
||||
$normalized = strtr($padded, '-_', '+/');
|
||||
|
||||
return base64_decode($normalized) ?: null;
|
||||
}
|
||||
|
||||
private function assertReturnToMatches(array $expectedQuery, ?string $encoded): void
|
||||
{
|
||||
$decoded = $this->decodeReturnTo($encoded);
|
||||
$this->assertNotNull($decoded, 'Failed to decode return_to parameter.');
|
||||
|
||||
$parts = parse_url($decoded);
|
||||
$this->assertSame('/api/v1/oauth/authorize', $parts['path'] ?? null);
|
||||
|
||||
$actualQuery = [];
|
||||
parse_str($parts['query'] ?? '', $actualQuery);
|
||||
|
||||
$this->assertEqualsCanonicalizing($expectedQuery, $actualQuery);
|
||||
}
|
||||
}
|
||||
@@ -1,284 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\OAuthClient;
|
||||
use App\Models\Tenant;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\File;
|
||||
use Illuminate\Support\Str;
|
||||
use Tests\TestCase;
|
||||
|
||||
class OAuthFlowTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
private const PUBLIC_KEY = <<<KEY
|
||||
-----BEGIN PUBLIC KEY-----
|
||||
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAlrZWbp/7pXo83BIJX3v/
|
||||
9f/51fxYFGZnZz9diqHkiOtDjggNdwze0LXruVeVb8YsaTI68RclgYCcsE4haTCG
|
||||
LlTivKFJL2O10IEzswjjD08MsanHer3xZRO6VZ7JLXmBNKp5C71zfFf8AhMnQ+Y6
|
||||
uGQ3wMOT6PWAiAmVBVYC8+KQsqyOkDu58bamhGGOrDsdWvrfDgRU1w8dxbgFYALQ
|
||||
v1pVVmYT9oBxZcS5FlT8auf8zLcHXEl6S7X61ZPd/GTWT5htdSiJyXfSa/xM7bJP
|
||||
CCv+mK6Gd5+1UG3RHGuwoi8Rch2O8PMglZqF6ybv/w836jUQKPl+sndePNN3soKQ
|
||||
5wIDAQAB
|
||||
-----END PUBLIC KEY-----
|
||||
KEY;
|
||||
|
||||
private const PRIVATE_KEY = <<<KEY
|
||||
-----BEGIN PRIVATE KEY-----
|
||||
MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCWtlZun/ulejzc
|
||||
Eglfe//1//nV/FgUZmdnP12KoeSI60OOCA13DN7Qteu5V5VvxixpMjrxFyWBgJyw
|
||||
TiFpMIYuVOK8oUkvY7XQgTOzCOMPTwyxqcd6vfFlE7pVnskteYE0qnkLvXN8V/wC
|
||||
EydD5jq4ZDfAw5Po9YCICZUFVgLz4pCyrI6QO7nxtqaEYY6sOx1a+t8OBFTXDx3F
|
||||
uAVgAtC/WlVWZhP2gHFlxLkWVPxq5/zMtwdcSXpLtfrVk938ZNZPmG11KInJd9Jr
|
||||
/Eztsk8IK/6YroZ3n7VQbdEca7CiLxFyHY7w8yCVmoXrJu//DzfqNRAo+X6yd148
|
||||
03eygpDnAgMBAAECggEAFoldk11I/A2zXBU2YZjhRZ/pdB4v7Z0CiWXoTvq2eeL0
|
||||
TyDVIqBCEWOixCxcpEI2EeT4+2RCr4LT62lDhb9D0VnQLfTQRM3cOjmXyYXirj9b
|
||||
3pVMxwXwOvUgP/1mh+5La9yyDRdfVZCylnzWukiLL1eNHr4gOA2+EpmcNxgNiPp1
|
||||
Z8USUp2kmSZMPmQDkGEAJnrqmW7LyBvda3yuW557WtpaQlHTprvNQdBIUoFhLiiS
|
||||
HnV9kZfQHM3BdM06zx8c7W6sbVavLQlaD0mhM6Z7o7566pq1JKScjhfoGcZRTmLs
|
||||
kshQVSf38ayhAz8CikWiJgqFJigIZI0bR9fROOy+wQKBgQDOWjVRq8Ql+Eu0so/B
|
||||
3hS1TGaBOFe5vymeX+hnC87Zu7yVsj96mhmofnlTJdbSZLHfO631XD9O3qCcYzuK
|
||||
1PLzOvO38ZVZLq/CkiwkC4qfGVQb3/8v0QyIXCKhMrwkwuL6AYMjQi6vd/+4vp2C
|
||||
5EJefbNBfdvsC90t84wxqBpIDQKBgQC6+Rs7cBD9VOAKkNH1O4k9cE1JCDX6aqlg
|
||||
RtO/93+kbqxz3llvIebI9z3CPE7Wp0n2GEFjvDCTy5kST7BQvdwm4VlthSpfhx+l
|
||||
4ahw1+xbB3KQxemmf3MroTZWHLfTOGvHdei05EIdRZv8Mpi9UcHd7OhVO82SUnLn
|
||||
pBqGLZGrwwKBgB2FiltE16sW+r2/ThHOU+gcJg4WoXZRgwLFddpINi+wTCqedbZ0
|
||||
lXcloPXkU/eFsGzffOO9btE5yICXMc2K6bcil/uY9GTt6PdNMkN14z8fwIi8YyXU
|
||||
Ipbfl5S4TXJ070QVM024CjXQVSV5H8+6GESsdxjHiM8cY2hPj58LDbeBAoGAfd5r
|
||||
FcVoupJjzNkXbwboagLrFGpBpFYfth+YN1hPhou27r3V6TmiWtIOsm7VCC5QXSqR
|
||||
AqpS7XwXjTs2T/Swe0AjatZF409c39gdA/JoPBO0bX++voZ4Kvv5T1k/6yLFc96N
|
||||
jRFI7NnKm6oYJwMeBt+QvKhoyMNWdViFPqT4tu8CgYEAmcInq55jIJOr7GNvf6jV
|
||||
wojrBxhEGOF8U8YqX6FgVEmVDkEOer3mFDnkZT/S2IFjH4eruo/ZTFFtyw9K9JGd
|
||||
06FINYtK/H91SdcOJHuWdELuTQw0+Jtr47tSUlp1c3L0J7Mt1Sqqzg8lLoLYPcLJ
|
||||
d7faJuYR8uKalWG3ZimbGNo=
|
||||
-----END PRIVATE KEY-----
|
||||
KEY;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
config()->set('oauth.keys.current_kid', 'test-kid');
|
||||
config()->set('oauth.keys.storage_path', storage_path('app/oauth-keys-tests'));
|
||||
|
||||
$paths = $this->keyPaths('test-kid');
|
||||
|
||||
File::ensureDirectoryExists($paths['directory']);
|
||||
File::put($paths['public'], self::PUBLIC_KEY);
|
||||
File::put($paths['private'], self::PRIVATE_KEY);
|
||||
File::chmod($paths['private'], 0600);
|
||||
File::chmod($paths['public'], 0644);
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
File::deleteDirectory(storage_path('app/oauth-keys-tests'));
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
public function test_authorization_code_flow_and_refresh(): void
|
||||
{
|
||||
$tenant = Tenant::factory()->create([
|
||||
'slug' => 'test-tenant',
|
||||
]);
|
||||
|
||||
OAuthClient::create([
|
||||
'id' => (string) Str::uuid(),
|
||||
'client_id' => 'tenant-admin-app',
|
||||
'tenant_id' => $tenant->id,
|
||||
'redirect_uris' => ['http://localhost/callback'],
|
||||
'scopes' => ['tenant:read', 'tenant:write'],
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
$codeVerifier = 'unit-test-code-verifier-1234567890';
|
||||
$codeChallenge = rtrim(strtr(base64_encode(hash('sha256', $codeVerifier, true)), '+/', '-_'), '=');
|
||||
$state = Str::random(10);
|
||||
|
||||
$response = $this->get('/api/v1/oauth/authorize?' . http_build_query([
|
||||
'client_id' => 'tenant-admin-app',
|
||||
'redirect_uri' => 'http://localhost/callback',
|
||||
'response_type' => 'code',
|
||||
'scope' => 'tenant:read tenant:write',
|
||||
'state' => $state,
|
||||
'code_challenge' => $codeChallenge,
|
||||
'code_challenge_method' => 'S256',
|
||||
]));
|
||||
|
||||
$response->assertRedirect();
|
||||
$location = $response->headers->get('Location');
|
||||
$this->assertNotNull($location);
|
||||
|
||||
$query = [];
|
||||
parse_str(parse_url($location, PHP_URL_QUERY) ?? '', $query);
|
||||
$authorizationCode = $query['code'] ?? null;
|
||||
$this->assertNotNull($authorizationCode, 'Authorization code should be present');
|
||||
$this->assertEquals($state, $query['state'] ?? null);
|
||||
|
||||
$tokenResponse = $this->post('/api/v1/oauth/token', [
|
||||
'grant_type' => 'authorization_code',
|
||||
'code' => $authorizationCode,
|
||||
'client_id' => 'tenant-admin-app',
|
||||
'redirect_uri' => 'http://localhost/callback',
|
||||
'code_verifier' => $codeVerifier,
|
||||
]);
|
||||
|
||||
$tokenResponse->assertOk();
|
||||
$tokenData = $tokenResponse->json();
|
||||
|
||||
$this->assertArrayHasKey('access_token', $tokenData);
|
||||
$this->assertArrayHasKey('refresh_token', $tokenData);
|
||||
$this->assertSame('Bearer', $tokenData['token_type']);
|
||||
|
||||
$meResponse = $this->get('/api/v1/tenant/me', [
|
||||
'Authorization' => 'Bearer ' . $tokenData['access_token'],
|
||||
]);
|
||||
|
||||
$meResponse->assertOk();
|
||||
$meResponse->assertJsonFragment([
|
||||
'tenant_id' => $tenant->id,
|
||||
'name' => $tenant->name,
|
||||
]);
|
||||
|
||||
$refreshResponse = $this->post('/api/v1/oauth/token', [
|
||||
'grant_type' => 'refresh_token',
|
||||
'refresh_token' => $tokenData['refresh_token'],
|
||||
'client_id' => 'tenant-admin-app',
|
||||
]);
|
||||
|
||||
$refreshResponse->assertOk();
|
||||
$refreshData = $refreshResponse->json();
|
||||
$this->assertArrayHasKey('access_token', $refreshData);
|
||||
$this->assertArrayHasKey('refresh_token', $refreshData);
|
||||
$this->assertNotEquals($refreshData['access_token'], $tokenData['access_token']);
|
||||
$this->withServerVariables(['REMOTE_ADDR' => '198.51.100.10'])
|
||||
->post('/api/v1/oauth/token', [
|
||||
'grant_type' => 'refresh_token',
|
||||
'refresh_token' => $refreshData['refresh_token'],
|
||||
'client_id' => 'tenant-admin-app',
|
||||
])
|
||||
->assertStatus(403)
|
||||
->assertJson([
|
||||
'error' => 'Refresh token cannot be used from this IP address',
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_refresh_token_ip_binding_can_be_disabled(): void
|
||||
{
|
||||
config()->set('oauth.refresh_tokens.enforce_ip_binding', false);
|
||||
|
||||
$tenant = Tenant::factory()->create([
|
||||
'slug' => 'ip-free',
|
||||
]);
|
||||
|
||||
OAuthClient::create([
|
||||
'id' => (string) Str::uuid(),
|
||||
'client_id' => 'tenant-admin-app',
|
||||
'tenant_id' => $tenant->id,
|
||||
'redirect_uris' => ['http://localhost/callback'],
|
||||
'scopes' => ['tenant:read'],
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
$codeVerifier = 'unit-test-code-verifier-abcdef';
|
||||
$codeChallenge = rtrim(strtr(base64_encode(hash('sha256', $codeVerifier, true)), '+/', '-_'), '=');
|
||||
|
||||
$codeResponse = $this->get('/api/v1/oauth/authorize?' . http_build_query([
|
||||
'client_id' => 'tenant-admin-app',
|
||||
'redirect_uri' => 'http://localhost/callback',
|
||||
'response_type' => 'code',
|
||||
'scope' => 'tenant:read',
|
||||
'state' => 'state',
|
||||
'code_challenge' => $codeChallenge,
|
||||
'code_challenge_method' => 'S256',
|
||||
]));
|
||||
|
||||
$location = $codeResponse->headers->get('Location');
|
||||
parse_str(parse_url($location, PHP_URL_QUERY) ?? '', $query);
|
||||
$code = $query['code'];
|
||||
|
||||
$tokenResponse = $this->post('/api/v1/oauth/token', [
|
||||
'grant_type' => 'authorization_code',
|
||||
'code' => $code,
|
||||
'client_id' => 'tenant-admin-app',
|
||||
'redirect_uri' => 'http://localhost/callback',
|
||||
'code_verifier' => $codeVerifier,
|
||||
]);
|
||||
|
||||
$token = $tokenResponse->json('refresh_token');
|
||||
$this->withServerVariables(['REMOTE_ADDR' => '203.0.113.33'])
|
||||
->post('/api/v1/oauth/token', [
|
||||
'grant_type' => 'refresh_token',
|
||||
'refresh_token' => $token,
|
||||
'client_id' => 'tenant-admin-app',
|
||||
])
|
||||
->assertOk();
|
||||
}
|
||||
|
||||
public function test_refresh_token_allows_same_subnet_when_enabled(): void
|
||||
{
|
||||
config()->set('oauth.refresh_tokens.allow_subnet_match', true);
|
||||
|
||||
$tenant = Tenant::factory()->create([
|
||||
'slug' => 'subnet-tenant',
|
||||
]);
|
||||
|
||||
OAuthClient::create([
|
||||
'id' => (string) Str::uuid(),
|
||||
'client_id' => 'tenant-admin-app',
|
||||
'tenant_id' => $tenant->id,
|
||||
'redirect_uris' => ['http://localhost/callback'],
|
||||
'scopes' => ['tenant:read'],
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
$codeVerifier = 'unit-test-code-verifier-subnet';
|
||||
$codeChallenge = rtrim(strtr(base64_encode(hash('sha256', $codeVerifier, true)), '+/', '-_'), '=');
|
||||
|
||||
$codeResponse = $this->get('/api/v1/oauth/authorize?' . http_build_query([
|
||||
'client_id' => 'tenant-admin-app',
|
||||
'redirect_uri' => 'http://localhost/callback',
|
||||
'response_type' => 'code',
|
||||
'scope' => 'tenant:read',
|
||||
'state' => 'state',
|
||||
'code_challenge' => $codeChallenge,
|
||||
'code_challenge_method' => 'S256',
|
||||
]));
|
||||
|
||||
$location = $codeResponse->headers->get('Location');
|
||||
parse_str(parse_url($location, PHP_URL_QUERY) ?? '', $query);
|
||||
$code = $query['code'];
|
||||
|
||||
$tokenResponse = $this->withServerVariables(['REMOTE_ADDR' => '198.51.100.24'])->post('/api/v1/oauth/token', [
|
||||
'grant_type' => 'authorization_code',
|
||||
'code' => $code,
|
||||
'client_id' => 'tenant-admin-app',
|
||||
'redirect_uri' => 'http://localhost/callback',
|
||||
'code_verifier' => $codeVerifier,
|
||||
]);
|
||||
|
||||
$token = $tokenResponse->json('refresh_token');
|
||||
|
||||
$this->withServerVariables(['REMOTE_ADDR' => '198.51.100.55'])
|
||||
->post('/api/v1/oauth/token', [
|
||||
'grant_type' => 'refresh_token',
|
||||
'refresh_token' => $token,
|
||||
'client_id' => 'tenant-admin-app',
|
||||
])
|
||||
->assertOk();
|
||||
}
|
||||
|
||||
private function keyPaths(string $kid): array
|
||||
{
|
||||
$base = storage_path('app/oauth-keys-tests');
|
||||
|
||||
return [
|
||||
'directory' => $base . DIRECTORY_SEPARATOR . $kid,
|
||||
'public' => $base . DIRECTORY_SEPARATOR . $kid . DIRECTORY_SEPARATOR . 'public.key',
|
||||
'private' => $base . DIRECTORY_SEPARATOR . $kid . DIRECTORY_SEPARATOR . 'private.key',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,10 +2,10 @@
|
||||
|
||||
namespace Tests\Feature\Tenant;
|
||||
|
||||
use App\Models\OAuthClient;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Str;
|
||||
use Tests\TestCase;
|
||||
|
||||
@@ -17,12 +17,8 @@ abstract class TenantTestCase extends TestCase
|
||||
|
||||
protected User $tenantUser;
|
||||
|
||||
protected OAuthClient $oauthClient;
|
||||
|
||||
protected string $token;
|
||||
|
||||
protected ?string $refreshToken = null;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
@@ -44,7 +40,7 @@ abstract class TenantTestCase extends TestCase
|
||||
$this->app->instance('tenant', $this->tenant);
|
||||
}
|
||||
|
||||
protected function initialiseTenantContext(array $scopes = ['tenant:read', 'tenant:write']): void
|
||||
protected function initialiseTenantContext(): void
|
||||
{
|
||||
$this->tenant = Tenant::factory()->create([
|
||||
'name' => 'Test Tenant',
|
||||
@@ -55,68 +51,18 @@ abstract class TenantTestCase extends TestCase
|
||||
'name' => 'Test User',
|
||||
'email' => 'test-'.Str::random(6).'@example.com',
|
||||
'tenant_id' => $this->tenant->id,
|
||||
'role' => 'admin',
|
||||
'role' => 'tenant_admin',
|
||||
'password' => Hash::make('password'),
|
||||
]);
|
||||
|
||||
$this->oauthClient = $this->createTenantClient($this->tenant, $scopes);
|
||||
[$this->token, $this->refreshToken] = $this->issueTokens($this->oauthClient, $scopes);
|
||||
$login = $this->postJson('/api/v1/tenant-auth/login', [
|
||||
'login' => $this->tenantUser->email,
|
||||
'password' => 'password',
|
||||
]);
|
||||
|
||||
$login->assertOk();
|
||||
$this->token = (string) $login->json('token');
|
||||
|
||||
$this->app->instance('tenant', $this->tenant);
|
||||
}
|
||||
|
||||
protected function createTenantClient(Tenant $tenant, array $scopes): OAuthClient
|
||||
{
|
||||
return OAuthClient::create([
|
||||
'id' => (string) Str::uuid(),
|
||||
'client_id' => 'tenant-admin-app-'.$tenant->id,
|
||||
'tenant_id' => $tenant->id,
|
||||
'client_secret' => null,
|
||||
'redirect_uris' => ['http://localhost/callback'],
|
||||
'scopes' => $scopes,
|
||||
'is_active' => true,
|
||||
]);
|
||||
}
|
||||
|
||||
protected function issueTokens(OAuthClient $client, array $scopes = ['tenant:read', 'tenant:write']): array
|
||||
{
|
||||
$this->actingAs($this->tenantUser);
|
||||
|
||||
$codeVerifier = 'tenant-code-verifier-'.Str::random(32);
|
||||
$codeChallenge = rtrim(strtr(base64_encode(hash('sha256', $codeVerifier, true)), '+/', '-_'), '=');
|
||||
$state = Str::random(10);
|
||||
|
||||
$response = $this->get('/api/v1/oauth/authorize?'.http_build_query([
|
||||
'client_id' => $client->client_id,
|
||||
'redirect_uri' => 'http://localhost/callback',
|
||||
'response_type' => 'code',
|
||||
'scope' => implode(' ', $scopes),
|
||||
'state' => $state,
|
||||
'code_challenge' => $codeChallenge,
|
||||
'code_challenge_method' => 'S256',
|
||||
]));
|
||||
|
||||
$response->assertRedirect();
|
||||
$location = $response->headers->get('Location');
|
||||
$this->assertNotNull($location);
|
||||
|
||||
$query = [];
|
||||
parse_str(parse_url($location, PHP_URL_QUERY) ?? '', $query);
|
||||
$authorizationCode = $query['code'] ?? null;
|
||||
$this->assertNotNull($authorizationCode, 'Authorization code should be present');
|
||||
|
||||
$tokenResponse = $this->post('/api/v1/oauth/token', [
|
||||
'grant_type' => 'authorization_code',
|
||||
'code' => $authorizationCode,
|
||||
'client_id' => $client->client_id,
|
||||
'redirect_uri' => 'http://localhost/callback',
|
||||
'code_verifier' => $codeVerifier,
|
||||
]);
|
||||
|
||||
$tokenResponse->assertOk();
|
||||
|
||||
return [
|
||||
$tokenResponse->json('access_token'),
|
||||
$tokenResponse->json('refresh_token'),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,10 +2,10 @@
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\OAuthClient;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Tests\TestCase;
|
||||
|
||||
class TenantCreditsTest extends TestCase
|
||||
@@ -19,16 +19,21 @@ class TenantCreditsTest extends TestCase
|
||||
'event_credits_balance' => 0,
|
||||
]);
|
||||
|
||||
$client = OAuthClient::create([
|
||||
'id' => (string) Str::uuid(),
|
||||
'client_id' => 'tenant-admin-app',
|
||||
$user = User::factory()->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'redirect_uris' => ['http://localhost/callback'],
|
||||
'scopes' => ['tenant:read', 'tenant:write'],
|
||||
'is_active' => true,
|
||||
'role' => 'tenant_admin',
|
||||
'email' => 'tenant-admin@example.com',
|
||||
'password' => Hash::make('password'),
|
||||
]);
|
||||
|
||||
[$accessToken] = $this->obtainTokens($client);
|
||||
$login = $this->postJson('/api/v1/tenant-auth/login', [
|
||||
'login' => $user->email,
|
||||
'password' => 'password',
|
||||
]);
|
||||
|
||||
$login->assertOk();
|
||||
|
||||
$accessToken = $login->json('token');
|
||||
|
||||
$headers = [
|
||||
'Authorization' => 'Bearer '.$accessToken,
|
||||
@@ -77,46 +82,5 @@ class TenantCreditsTest extends TestCase
|
||||
$syncResponse->assertOk()
|
||||
->assertJsonStructure(['balance', 'subscription_active', 'server_time']);
|
||||
}
|
||||
|
||||
private function obtainTokens(OAuthClient $client): array
|
||||
{
|
||||
$codeVerifier = 'tenant-credits-code-verifier-1234567890';
|
||||
$codeChallenge = rtrim(strtr(base64_encode(hash('sha256', $codeVerifier, true)), '+/', '-_'), '=');
|
||||
$state = Str::random(10);
|
||||
|
||||
$response = $this->get('/api/v1/oauth/authorize?' . http_build_query([
|
||||
'client_id' => $client->client_id,
|
||||
'redirect_uri' => 'http://localhost/callback',
|
||||
'response_type' => 'code',
|
||||
'scope' => 'tenant:read tenant:write',
|
||||
'state' => $state,
|
||||
'code_challenge' => $codeChallenge,
|
||||
'code_challenge_method' => 'S256',
|
||||
]));
|
||||
|
||||
$response->assertRedirect();
|
||||
$location = $response->headers->get('Location');
|
||||
$this->assertNotNull($location);
|
||||
|
||||
$query = [];
|
||||
parse_str(parse_url($location, PHP_URL_QUERY) ?? '', $query);
|
||||
$authorizationCode = $query['code'] ?? null;
|
||||
$this->assertNotNull($authorizationCode, 'Authorization code should be present');
|
||||
|
||||
$tokenResponse = $this->post('/api/v1/oauth/token', [
|
||||
'grant_type' => 'authorization_code',
|
||||
'code' => $authorizationCode,
|
||||
'client_id' => $client->client_id,
|
||||
'redirect_uri' => 'http://localhost/callback',
|
||||
'code_verifier' => $codeVerifier,
|
||||
]);
|
||||
|
||||
$tokenResponse->assertOk();
|
||||
|
||||
return [
|
||||
$tokenResponse->json('access_token'),
|
||||
$tokenResponse->json('refresh_token'),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,76 +0,0 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test('OAuth Flow for tenant-admin-app', async ({ page }) => {
|
||||
const code_challenge = 'dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk';
|
||||
const code_verifier = 'E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM';
|
||||
const redirect_uri = 'http://localhost:8000/auth/callback';
|
||||
const state = 'teststate';
|
||||
const scope = 'tenant:read tenant:write tenant:admin';
|
||||
|
||||
const authorizeUrl = `/api/v1/oauth/authorize?response_type=code&client_id=tenant-admin-app&redirect_uri=${encodeURIComponent(redirect_uri)}&scope=${encodeURIComponent(scope)}&code_challenge=${code_challenge}&code_challenge_method=S256&state=${state}`;
|
||||
|
||||
// Navigate to authorize - should immediately redirect to callback
|
||||
await page.goto(authorizeUrl);
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
// Log response if no redirect
|
||||
const currentUrl = page.url();
|
||||
if (currentUrl.includes('/authorize')) {
|
||||
const response = await page.content();
|
||||
console.log('No redirect, response:', response.substring(0, 500)); // First 500 chars
|
||||
}
|
||||
|
||||
// Wait for redirect to callback and parse params
|
||||
await expect(page).toHaveURL(new RegExp(`${redirect_uri}\\?.*`));
|
||||
const urlObj = new URL(currentUrl);
|
||||
const code = urlObj.searchParams.get('code') || '';
|
||||
const receivedState = urlObj.searchParams.get('state') || '';
|
||||
|
||||
expect(receivedState).toBe(state);
|
||||
expect(code).not.toBeNull();
|
||||
|
||||
console.log('Authorization code:', code);
|
||||
|
||||
// Token exchange via fetch
|
||||
const tokenParams = {
|
||||
code: code!,
|
||||
redirect_uri,
|
||||
code_verifier
|
||||
};
|
||||
const tokenResponse = await page.evaluate(async (params) => {
|
||||
const response = await fetch('/api/v1/oauth/token', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
grant_type: 'authorization_code',
|
||||
client_id: 'tenant-admin-app',
|
||||
code: params.code,
|
||||
redirect_uri: params.redirect_uri,
|
||||
code_verifier: params.code_verifier,
|
||||
}).toString(),
|
||||
});
|
||||
return await response.json();
|
||||
}, tokenParams);
|
||||
|
||||
console.log('Token response:', tokenResponse);
|
||||
expect(tokenResponse.access_token).toBeTruthy();
|
||||
|
||||
const accessToken = tokenResponse.access_token;
|
||||
|
||||
// Call /tenant/me with token
|
||||
const meResponse = await page.evaluate(async (token) => {
|
||||
const response = await fetch('/api/v1/tenant/me', {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
});
|
||||
return await response.json();
|
||||
}, accessToken);
|
||||
|
||||
console.log('/tenant/me response:', meResponse);
|
||||
expect(meResponse).toHaveProperty('id');
|
||||
expect(meResponse.email).toBe('demo@example.com');
|
||||
});
|
||||
@@ -1,6 +1,5 @@
|
||||
import 'dotenv/config';
|
||||
import { test as base, expect, Page, APIRequestContext } from '@playwright/test';
|
||||
import { randomBytes, createHash } from 'node:crypto';
|
||||
|
||||
export type TenantCredentials = {
|
||||
email: string;
|
||||
@@ -44,16 +43,13 @@ export const test = base.extend<TenantAdminFixtures>({
|
||||
|
||||
export const expectFixture = expect;
|
||||
|
||||
const clientId = process.env.VITE_OAUTH_CLIENT_ID ?? 'tenant-admin-app';
|
||||
const redirectUri = new URL('/event-admin/auth/callback', process.env.PLAYWRIGHT_BASE_URL ?? 'http://localhost:8000').toString();
|
||||
const scopes = (process.env.VITE_OAUTH_SCOPES as string | undefined) ?? 'tenant:read tenant:write';
|
||||
|
||||
async function performTenantSignIn(page: Page, _credentials: TenantCredentials) {
|
||||
const tokens = await exchangeTokens(page.request);
|
||||
async function performTenantSignIn(page: Page, credentials: TenantCredentials) {
|
||||
const token = await exchangeToken(page.request, credentials);
|
||||
|
||||
await page.addInitScript(({ stored }) => {
|
||||
localStorage.setItem('tenant_oauth_tokens.v1', JSON.stringify(stored));
|
||||
}, { stored: tokens });
|
||||
localStorage.setItem('tenant_admin.token.v1', JSON.stringify(stored));
|
||||
sessionStorage.setItem('tenant_admin.token.session.v1', JSON.stringify(stored));
|
||||
}, { stored: token });
|
||||
|
||||
await page.goto('/event-admin');
|
||||
await page.waitForLoadState('domcontentloaded');
|
||||
@@ -61,78 +57,27 @@ async function performTenantSignIn(page: Page, _credentials: TenantCredentials)
|
||||
|
||||
type StoredTokenPayload = {
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
expiresAt: number;
|
||||
scope?: string;
|
||||
clientId?: string;
|
||||
abilities: string[];
|
||||
issuedAt: number;
|
||||
};
|
||||
|
||||
async function exchangeTokens(request: APIRequestContext): Promise<StoredTokenPayload> {
|
||||
const verifier = generateCodeVerifier();
|
||||
const challenge = generateCodeChallenge(verifier);
|
||||
const state = randomBytes(12).toString('hex');
|
||||
|
||||
const params = new URLSearchParams({
|
||||
response_type: 'code',
|
||||
client_id: clientId,
|
||||
redirect_uri: redirectUri,
|
||||
scope: scopes,
|
||||
state,
|
||||
code_challenge: challenge,
|
||||
code_challenge_method: 'S256',
|
||||
});
|
||||
|
||||
const authResponse = await request.get(`/api/v1/oauth/authorize?${params.toString()}`, {
|
||||
maxRedirects: 0,
|
||||
headers: {
|
||||
'x-playwright-test': 'tenant-admin',
|
||||
async function exchangeToken(request: APIRequestContext, credentials: TenantCredentials): Promise<StoredTokenPayload> {
|
||||
const response = await request.post('/api/v1/tenant-auth/login', {
|
||||
data: {
|
||||
login: credentials.email,
|
||||
password: credentials.password,
|
||||
},
|
||||
});
|
||||
|
||||
if (authResponse.status() >= 400) {
|
||||
throw new Error(`OAuth authorize failed: ${authResponse.status()} ${await authResponse.text()}`);
|
||||
if (!response.ok()) {
|
||||
throw new Error(`Tenant PAT login failed: ${response.status()} ${await response.text()}`);
|
||||
}
|
||||
|
||||
const location = authResponse.headers()['location'];
|
||||
if (!location) {
|
||||
throw new Error('OAuth authorize did not return redirect location');
|
||||
}
|
||||
|
||||
const code = new URL(location).searchParams.get('code');
|
||||
if (!code) {
|
||||
throw new Error('OAuth authorize response missing code');
|
||||
}
|
||||
|
||||
const tokenResponse = await request.post('/api/v1/oauth/token', {
|
||||
form: {
|
||||
grant_type: 'authorization_code',
|
||||
code,
|
||||
client_id: clientId,
|
||||
redirect_uri: redirectUri,
|
||||
code_verifier: verifier,
|
||||
},
|
||||
});
|
||||
|
||||
if (!tokenResponse.ok()) {
|
||||
throw new Error(`OAuth token exchange failed: ${tokenResponse.status()} ${await tokenResponse.text()}`);
|
||||
}
|
||||
|
||||
const body = await tokenResponse.json();
|
||||
const expiresIn = typeof body.expires_in === 'number' ? body.expires_in : 3600;
|
||||
const body = await response.json();
|
||||
|
||||
return {
|
||||
accessToken: body.access_token,
|
||||
refreshToken: body.refresh_token,
|
||||
expiresAt: Date.now() + Math.max(expiresIn - 30, 0) * 1000,
|
||||
scope: body.scope,
|
||||
clientId,
|
||||
accessToken: body.token,
|
||||
abilities: Array.isArray(body.abilities) ? body.abilities : [],
|
||||
issuedAt: Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
function generateCodeVerifier(): string {
|
||||
return randomBytes(32).toString('base64url');
|
||||
}
|
||||
|
||||
function generateCodeChallenge(verifier: string): string {
|
||||
return createHash('sha256').update(verifier).digest('base64url');
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user