stage 2 of oauth removal, switch to sanctum pat tokens completed, docs updated

This commit is contained in:
Codex Agent
2025-11-07 07:46:53 +01:00
parent 776da57ca9
commit 67affd3317
41 changed files with 124 additions and 2148 deletions

View File

@@ -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'),
];
}
}

View File

@@ -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');
}
}

View File

@@ -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');
}
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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}'),
];
}
}

View File

@@ -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 [];
}
}

View File

@@ -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'));
}),
];
}
}

View File

@@ -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'));
}
}

View File

@@ -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 */

View File

@@ -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;
} }

View File

@@ -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);
}
}

View File

@@ -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();
}
}

View File

@@ -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(),
]);
}
}

View File

@@ -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');
}
}

View File

@@ -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();
}
}

View File

@@ -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';
}
}

View File

@@ -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'));
}); });

View File

@@ -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
}); });
} }
} }

View File

@@ -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;
} }
} }

View File

@@ -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;

View File

@@ -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),
],
];

View File

@@ -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))));
})(),
],
],
]; ];

View File

@@ -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');
}
};

View File

@@ -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');
}
});
}
};

View File

@@ -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'])) {

View File

@@ -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();
}
}

View File

@@ -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();
}
} }

View File

@@ -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' => 'TenantAdmin', 'tenant_admin_title' => 'TenantAdmin',
], ],

View File

@@ -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',
], ],

View File

@@ -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');

View File

@@ -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);
}
}

View File

@@ -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), '+/', '-_'), '=');

View File

@@ -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([

View File

@@ -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

View File

@@ -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);
}
}

View File

@@ -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',
];
}
}

View File

@@ -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'),
];
}
} }

View File

@@ -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'),
];
}
} }

View File

@@ -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');
});

View File

@@ -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');
}