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
|
public function exchange(Request $request): JsonResponse
|
||||||
{
|
{
|
||||||
/** @var User|null $user */
|
/** @var User|null $user */
|
||||||
|
|||||||
@@ -209,7 +209,7 @@ class AuthenticatedSessionController extends Controller
|
|||||||
$path = '/'.$path;
|
$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;
|
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);
|
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) {
|
RateLimiter::for('tenant-auth', function (Request $request) {
|
||||||
return Limit::perMinute(20)->by('tenant-auth:'.($request->ip() ?? 'unknown'));
|
return Limit::perMinute(20)->by('tenant-auth:'.($request->ip() ?? 'unknown'));
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,11 +2,9 @@
|
|||||||
|
|
||||||
namespace App\Providers;
|
namespace App\Providers;
|
||||||
|
|
||||||
use App\Models\OAuthClient;
|
|
||||||
use App\Models\PurchaseHistory;
|
use App\Models\PurchaseHistory;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Policies\OAuthClientPolicy;
|
|
||||||
use App\Policies\PurchaseHistoryPolicy;
|
use App\Policies\PurchaseHistoryPolicy;
|
||||||
use App\Policies\TenantPolicy;
|
use App\Policies\TenantPolicy;
|
||||||
use Illuminate\Foundation\Support\Providers\AuthServiceProvider as ServiceProvider;
|
use Illuminate\Foundation\Support\Providers\AuthServiceProvider as ServiceProvider;
|
||||||
@@ -22,7 +20,6 @@ class AuthServiceProvider extends ServiceProvider
|
|||||||
protected $policies = [
|
protected $policies = [
|
||||||
Tenant::class => TenantPolicy::class,
|
Tenant::class => TenantPolicy::class,
|
||||||
PurchaseHistory::class => PurchaseHistoryPolicy::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 App\Models\User;
|
||||||
use Illuminate\Database\Eloquent\ModelNotFoundException;
|
use Illuminate\Database\Eloquent\ModelNotFoundException;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Support\Arr;
|
|
||||||
|
|
||||||
class TenantAuth
|
class TenantAuth
|
||||||
{
|
{
|
||||||
@@ -16,24 +15,17 @@ class TenantAuth
|
|||||||
*/
|
*/
|
||||||
public static function resolveAdminUser(Request $request): User
|
public static function resolveAdminUser(Request $request): User
|
||||||
{
|
{
|
||||||
$decoded = (array) $request->attributes->get('decoded_token', []);
|
|
||||||
$tenantId = $request->attributes->get('tenant_id')
|
$tenantId = $request->attributes->get('tenant_id')
|
||||||
?? $request->input('tenant_id')
|
?? $request->input('tenant_id')
|
||||||
?? Arr::get($decoded, 'tenant_id');
|
?? $request->user()?->tenant_id;
|
||||||
|
|
||||||
if (! $tenantId) {
|
if (! $tenantId) {
|
||||||
throw (new ModelNotFoundException)->setModel(User::class);
|
throw (new ModelNotFoundException)->setModel(User::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
$userId = Arr::get($decoded, 'user_id');
|
$user = $request->user();
|
||||||
|
if ($user && in_array($user->role, ['tenant_admin', 'admin', 'super_admin'], true)) {
|
||||||
if ($userId) {
|
if ($user->role !== 'super_admin' || (int) $user->tenant_id === (int) $tenantId) {
|
||||||
$user = User::query()
|
|
||||||
->whereKey($userId)
|
|
||||||
->where('tenant_id', $tenantId)
|
|
||||||
->first();
|
|
||||||
|
|
||||||
if ($user) {
|
|
||||||
return $user;
|
return $user;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ use App\Http\Middleware\HandleAppearance;
|
|||||||
use App\Http\Middleware\HandleInertiaRequests;
|
use App\Http\Middleware\HandleInertiaRequests;
|
||||||
use App\Http\Middleware\SetLocaleFromUser;
|
use App\Http\Middleware\SetLocaleFromUser;
|
||||||
use App\Http\Middleware\TenantIsolation;
|
use App\Http\Middleware\TenantIsolation;
|
||||||
use App\Http\Middleware\TenantTokenGuard;
|
|
||||||
use Illuminate\Foundation\Application;
|
use Illuminate\Foundation\Application;
|
||||||
use Illuminate\Foundation\Configuration\Exceptions;
|
use Illuminate\Foundation\Configuration\Exceptions;
|
||||||
use Illuminate\Foundation\Configuration\Middleware;
|
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'),
|
'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,
|
SuperAdminSeeder::class,
|
||||||
DemoTenantSeeder::class,
|
DemoTenantSeeder::class,
|
||||||
DemoEventSeeder::class,
|
DemoEventSeeder::class,
|
||||||
OAuthClientSeeder::class,
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (app()->environment(['local', 'development', 'demo'])) {
|
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\Event;
|
||||||
use App\Models\EventPackage;
|
use App\Models\EventPackage;
|
||||||
use App\Models\EventType;
|
use App\Models\EventType;
|
||||||
use App\Models\OAuthClient;
|
|
||||||
use App\Models\Package;
|
use App\Models\Package;
|
||||||
use App\Models\PackagePurchase;
|
use App\Models\PackagePurchase;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
@@ -125,7 +124,6 @@ class DemoLifecycleSeeder extends Seeder
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
$this->createTenantAdmin($tenant, 'storycraft-owner@demo.fotospiel');
|
$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
|
private function seedActiveTenant(Package $standard, Package $premium, EventType $weddingType, EventType $corporateType): void
|
||||||
@@ -139,12 +137,7 @@ class DemoLifecycleSeeder extends Seeder
|
|||||||
'is_active' => true,
|
'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->createTenantAdmin($tenant, 'hello@lumen-moments.demo');
|
||||||
$this->ensureOAuthClientForTenant($tenant, 'demo-tenant-admin-lumen');
|
|
||||||
|
|
||||||
$purchase = PackagePurchase::create([
|
$purchase = PackagePurchase::create([
|
||||||
'tenant_id' => $tenant->id,
|
'tenant_id' => $tenant->id,
|
||||||
@@ -210,7 +203,6 @@ class DemoLifecycleSeeder extends Seeder
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
$this->createTenantAdmin($tenant, 'team@viewfinder.demo');
|
$this->createTenantAdmin($tenant, 'team@viewfinder.demo');
|
||||||
$this->ensureOAuthClientForTenant($tenant, 'demo-tenant-admin-viewfinder');
|
|
||||||
|
|
||||||
$tenantPackage = TenantPackage::create([
|
$tenantPackage = TenantPackage::create([
|
||||||
'tenant_id' => $tenant->id,
|
'tenant_id' => $tenant->id,
|
||||||
@@ -280,7 +272,6 @@ class DemoLifecycleSeeder extends Seeder
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
$this->createTenantAdmin($tenant, 'support@pixelco.demo', role: 'member');
|
$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
|
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.',
|
'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' => [
|
'shell' => [
|
||||||
'tenant_admin_title' => 'Tenant‑Admin',
|
'tenant_admin_title' => 'Tenant‑Admin',
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -289,110 +289,6 @@ return [
|
|||||||
'export_success' => 'Export ready. :count rows exported.',
|
'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' => [
|
'shell' => [
|
||||||
'tenant_admin_title' => 'Tenant Admin',
|
'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\Tenant\TenantFeedbackController;
|
||||||
use App\Http\Controllers\Api\TenantBillingController;
|
use App\Http\Controllers\Api\TenantBillingController;
|
||||||
use App\Http\Controllers\Api\TenantPackageController;
|
use App\Http\Controllers\Api\TenantPackageController;
|
||||||
use App\Http\Controllers\OAuthController;
|
|
||||||
use App\Http\Controllers\RevenueCatWebhookController;
|
use App\Http\Controllers\RevenueCatWebhookController;
|
||||||
use App\Http\Controllers\Tenant\CreditController;
|
use App\Http\Controllers\Tenant\CreditController;
|
||||||
use Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse;
|
use Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse;
|
||||||
@@ -32,11 +31,6 @@ Route::prefix('v1')->name('api.v1.')->group(function () {
|
|||||||
->middleware('throttle:60,1')
|
->middleware('throttle:60,1')
|
||||||
->name('webhooks.revenuecat');
|
->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::prefix('tenant-auth')->name('tenant-auth.')->group(function () {
|
||||||
Route::post('/login', [TenantAdminTokenController::class, 'store'])
|
Route::post('/login', [TenantAdminTokenController::class, 'store'])
|
||||||
->middleware('throttle:tenant-auth')
|
->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::put('profile', [ProfileController::class, 'update'])->name('tenant.profile.update');
|
||||||
Route::get('onboarding', [OnboardingController::class, 'show'])->name('tenant.onboarding.show');
|
Route::get('onboarding', [OnboardingController::class, 'show'])->name('tenant.onboarding.show');
|
||||||
Route::post('onboarding', [OnboardingController::class, 'store'])->name('tenant.onboarding.store');
|
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('dashboard', DashboardController::class)->name('tenant.dashboard');
|
||||||
Route::get('event-types', EventTypeController::class)->name('tenant.event-types.index');
|
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(),
|
'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';
|
$returnTarget = '/event-admin/dashboard';
|
||||||
$encodedReturn = rtrim(strtr(base64_encode($returnTarget), '+/', '-_'), '=');
|
$encodedReturn = rtrim(strtr(base64_encode($returnTarget), '+/', '-_'), '=');
|
||||||
|
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ class TenantAdminGoogleControllerTest extends TestCase
|
|||||||
Socialite::shouldReceive('driver')->once()->with('google')->andReturn($driver);
|
Socialite::shouldReceive('driver')->once()->with('google')->andReturn($driver);
|
||||||
$driver->shouldReceive('user')->once()->andReturn($socialiteUser);
|
$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), '+/', '-_'), '=');
|
$encodedReturn = rtrim(strtr(base64_encode($targetUrl), '+/', '-_'), '=');
|
||||||
|
|
||||||
$this->withSession([
|
$this->withSession([
|
||||||
|
|||||||
@@ -27,6 +27,8 @@ class TenantProfileApiTest extends TestCase
|
|||||||
'password' => Hash::make('secret-password'),
|
'password' => Hash::make('secret-password'),
|
||||||
'email' => 'tenant@example.com',
|
'email' => 'tenant@example.com',
|
||||||
'name' => 'Max Mustermann',
|
'name' => 'Max Mustermann',
|
||||||
|
'first_name' => 'Max',
|
||||||
|
'last_name' => 'Mustermann',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$login = $this->postJson('/api/v1/tenant-auth/login', [
|
$login = $this->postJson('/api/v1/tenant-auth/login', [
|
||||||
@@ -57,6 +59,34 @@ class TenantProfileApiTest extends TestCase
|
|||||||
$data = $me->json();
|
$data = $me->json();
|
||||||
$this->assertEquals('Max Mustermann', data_get($data, 'user.name'));
|
$this->assertEquals('Max Mustermann', data_get($data, 'user.name'));
|
||||||
$this->assertContains('tenant-admin', $data['abilities']);
|
$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
|
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;
|
namespace Tests\Feature\Tenant;
|
||||||
|
|
||||||
use App\Models\OAuthClient;
|
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Illuminate\Support\Facades\Hash;
|
||||||
use Illuminate\Support\Str;
|
use Illuminate\Support\Str;
|
||||||
use Tests\TestCase;
|
use Tests\TestCase;
|
||||||
|
|
||||||
@@ -17,12 +17,8 @@ abstract class TenantTestCase extends TestCase
|
|||||||
|
|
||||||
protected User $tenantUser;
|
protected User $tenantUser;
|
||||||
|
|
||||||
protected OAuthClient $oauthClient;
|
|
||||||
|
|
||||||
protected string $token;
|
protected string $token;
|
||||||
|
|
||||||
protected ?string $refreshToken = null;
|
|
||||||
|
|
||||||
protected function setUp(): void
|
protected function setUp(): void
|
||||||
{
|
{
|
||||||
parent::setUp();
|
parent::setUp();
|
||||||
@@ -44,7 +40,7 @@ abstract class TenantTestCase extends TestCase
|
|||||||
$this->app->instance('tenant', $this->tenant);
|
$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([
|
$this->tenant = Tenant::factory()->create([
|
||||||
'name' => 'Test Tenant',
|
'name' => 'Test Tenant',
|
||||||
@@ -55,68 +51,18 @@ abstract class TenantTestCase extends TestCase
|
|||||||
'name' => 'Test User',
|
'name' => 'Test User',
|
||||||
'email' => 'test-'.Str::random(6).'@example.com',
|
'email' => 'test-'.Str::random(6).'@example.com',
|
||||||
'tenant_id' => $this->tenant->id,
|
'tenant_id' => $this->tenant->id,
|
||||||
'role' => 'admin',
|
'role' => 'tenant_admin',
|
||||||
|
'password' => Hash::make('password'),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$this->oauthClient = $this->createTenantClient($this->tenant, $scopes);
|
$login = $this->postJson('/api/v1/tenant-auth/login', [
|
||||||
[$this->token, $this->refreshToken] = $this->issueTokens($this->oauthClient, $scopes);
|
'login' => $this->tenantUser->email,
|
||||||
|
'password' => 'password',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$login->assertOk();
|
||||||
|
$this->token = (string) $login->json('token');
|
||||||
|
|
||||||
$this->app->instance('tenant', $this->tenant);
|
$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;
|
namespace Tests\Feature;
|
||||||
|
|
||||||
use App\Models\OAuthClient;
|
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
|
use App\Models\User;
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
use Illuminate\Support\Str;
|
use Illuminate\Support\Facades\Hash;
|
||||||
use Tests\TestCase;
|
use Tests\TestCase;
|
||||||
|
|
||||||
class TenantCreditsTest extends TestCase
|
class TenantCreditsTest extends TestCase
|
||||||
@@ -19,16 +19,21 @@ class TenantCreditsTest extends TestCase
|
|||||||
'event_credits_balance' => 0,
|
'event_credits_balance' => 0,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$client = OAuthClient::create([
|
$user = User::factory()->create([
|
||||||
'id' => (string) Str::uuid(),
|
|
||||||
'client_id' => 'tenant-admin-app',
|
|
||||||
'tenant_id' => $tenant->id,
|
'tenant_id' => $tenant->id,
|
||||||
'redirect_uris' => ['http://localhost/callback'],
|
'role' => 'tenant_admin',
|
||||||
'scopes' => ['tenant:read', 'tenant:write'],
|
'email' => 'tenant-admin@example.com',
|
||||||
'is_active' => true,
|
'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 = [
|
$headers = [
|
||||||
'Authorization' => 'Bearer '.$accessToken,
|
'Authorization' => 'Bearer '.$accessToken,
|
||||||
@@ -77,46 +82,5 @@ class TenantCreditsTest extends TestCase
|
|||||||
$syncResponse->assertOk()
|
$syncResponse->assertOk()
|
||||||
->assertJsonStructure(['balance', 'subscription_active', 'server_time']);
|
->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 'dotenv/config';
|
||||||
import { test as base, expect, Page, APIRequestContext } from '@playwright/test';
|
import { test as base, expect, Page, APIRequestContext } from '@playwright/test';
|
||||||
import { randomBytes, createHash } from 'node:crypto';
|
|
||||||
|
|
||||||
export type TenantCredentials = {
|
export type TenantCredentials = {
|
||||||
email: string;
|
email: string;
|
||||||
@@ -44,16 +43,13 @@ export const test = base.extend<TenantAdminFixtures>({
|
|||||||
|
|
||||||
export const expectFixture = expect;
|
export const expectFixture = expect;
|
||||||
|
|
||||||
const clientId = process.env.VITE_OAUTH_CLIENT_ID ?? 'tenant-admin-app';
|
async function performTenantSignIn(page: Page, credentials: TenantCredentials) {
|
||||||
const redirectUri = new URL('/event-admin/auth/callback', process.env.PLAYWRIGHT_BASE_URL ?? 'http://localhost:8000').toString();
|
const token = await exchangeToken(page.request, credentials);
|
||||||
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);
|
|
||||||
|
|
||||||
await page.addInitScript(({ stored }) => {
|
await page.addInitScript(({ stored }) => {
|
||||||
localStorage.setItem('tenant_oauth_tokens.v1', JSON.stringify(stored));
|
localStorage.setItem('tenant_admin.token.v1', JSON.stringify(stored));
|
||||||
}, { stored: tokens });
|
sessionStorage.setItem('tenant_admin.token.session.v1', JSON.stringify(stored));
|
||||||
|
}, { stored: token });
|
||||||
|
|
||||||
await page.goto('/event-admin');
|
await page.goto('/event-admin');
|
||||||
await page.waitForLoadState('domcontentloaded');
|
await page.waitForLoadState('domcontentloaded');
|
||||||
@@ -61,78 +57,27 @@ async function performTenantSignIn(page: Page, _credentials: TenantCredentials)
|
|||||||
|
|
||||||
type StoredTokenPayload = {
|
type StoredTokenPayload = {
|
||||||
accessToken: string;
|
accessToken: string;
|
||||||
refreshToken: string;
|
abilities: string[];
|
||||||
expiresAt: number;
|
issuedAt: number;
|
||||||
scope?: string;
|
|
||||||
clientId?: string;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
async function exchangeTokens(request: APIRequestContext): Promise<StoredTokenPayload> {
|
async function exchangeToken(request: APIRequestContext, credentials: TenantCredentials): Promise<StoredTokenPayload> {
|
||||||
const verifier = generateCodeVerifier();
|
const response = await request.post('/api/v1/tenant-auth/login', {
|
||||||
const challenge = generateCodeChallenge(verifier);
|
data: {
|
||||||
const state = randomBytes(12).toString('hex');
|
login: credentials.email,
|
||||||
|
password: credentials.password,
|
||||||
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',
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (authResponse.status() >= 400) {
|
if (!response.ok()) {
|
||||||
throw new Error(`OAuth authorize failed: ${authResponse.status()} ${await authResponse.text()}`);
|
throw new Error(`Tenant PAT login failed: ${response.status()} ${await response.text()}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const location = authResponse.headers()['location'];
|
const body = await response.json();
|
||||||
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;
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
accessToken: body.access_token,
|
accessToken: body.token,
|
||||||
refreshToken: body.refresh_token,
|
abilities: Array.isArray(body.abilities) ? body.abilities : [],
|
||||||
expiresAt: Date.now() + Math.max(expiresIn - 30, 0) * 1000,
|
issuedAt: Date.now(),
|
||||||
scope: body.scope,
|
|
||||||
clientId,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
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