From 67affd3317a0152dc39fe566b5a976ace44e67fe Mon Sep 17 00:00:00 2001 From: Codex Agent Date: Fri, 7 Nov 2025 07:46:53 +0100 Subject: [PATCH] stage 2 of oauth removal, switch to sanctum pat tokens completed, docs updated --- .../Resources/OAuthClientResource.php | 190 ------------ .../Pages/CreateOAuthClient.php | 17 -- .../Pages/EditOAuthClient.php | 25 -- .../Pages/ListOAuthClients.php | 12 - .../Pages/ViewOAuthClient.php | 12 - .../Resources/RefreshTokenResource.php | 200 ------------ .../Pages/ListRefreshTokens.php | 17 -- .../Pages/ViewRefreshToken.php | 54 ---- .../AuditsRelationManager.php | 69 ----- .../Api/Tenant/TenantAdminTokenController.php | 43 +++ .../Auth/AuthenticatedSessionController.php | 2 +- app/Models/OAuthClient.php | 39 --- app/Models/OAuthCode.php | 47 --- app/Models/RefreshToken.php | 120 -------- app/Models/RefreshTokenAudit.php | 46 --- app/Models/TenantToken.php | 51 ---- app/Policies/OAuthClientPolicy.php | 38 --- app/Providers/AppServiceProvider.php | 4 - app/Providers/AuthServiceProvider.php | 4 - app/Support/TenantAuth.php | 16 +- bootstrap/app.php | 1 - config/oauth.php | 14 - config/services.php | 23 -- ...0300_create_refresh_token_audits_table.php | 34 --- ...000400_add_last_used_to_refresh_tokens.php | 34 --- database/seeders/DatabaseSeeder.php | 1 - database/seeders/OAuthClientSeeder.php | 53 ---- database/seeders/_DemoLifecycleSeeder.php | 35 --- resources/lang/de/admin.php | 104 ------- resources/lang/en/admin.php | 104 ------- routes/api.php | 8 +- .../Api/Tenant/TenantTokenGuardTest.php | 26 -- tests/Feature/Auth/LoginTest.php | 2 +- .../Auth/TenantAdminGoogleControllerTest.php | 2 +- tests/Feature/Auth/TenantProfileApiTest.php | 30 ++ tests/Feature/OAuth/AuthorizeTest.php | 204 ------------- tests/Feature/OAuthFlowTest.php | 284 ------------------ tests/Feature/Tenant/TenantTestCase.php | 76 +---- tests/Feature/TenantCreditsTest.php | 64 +--- tests/e2e/oauth-flow.test.ts | 76 ----- tests/e2e/utils/test-fixtures.ts | 91 ++---- 41 files changed, 124 insertions(+), 2148 deletions(-) delete mode 100644 app/Filament/Resources/OAuthClientResource.php delete mode 100644 app/Filament/Resources/OAuthClientResource/Pages/CreateOAuthClient.php delete mode 100644 app/Filament/Resources/OAuthClientResource/Pages/EditOAuthClient.php delete mode 100644 app/Filament/Resources/OAuthClientResource/Pages/ListOAuthClients.php delete mode 100644 app/Filament/Resources/OAuthClientResource/Pages/ViewOAuthClient.php delete mode 100644 app/Filament/Resources/RefreshTokenResource.php delete mode 100644 app/Filament/Resources/RefreshTokenResource/Pages/ListRefreshTokens.php delete mode 100644 app/Filament/Resources/RefreshTokenResource/Pages/ViewRefreshToken.php delete mode 100644 app/Filament/Resources/RefreshTokenResource/RelationManagers/AuditsRelationManager.php delete mode 100644 app/Models/OAuthClient.php delete mode 100644 app/Models/OAuthCode.php delete mode 100644 app/Models/RefreshToken.php delete mode 100644 app/Models/RefreshTokenAudit.php delete mode 100644 app/Models/TenantToken.php delete mode 100644 app/Policies/OAuthClientPolicy.php delete mode 100644 config/oauth.php delete mode 100644 database/migrations/2025_10_22_000300_create_refresh_token_audits_table.php delete mode 100644 database/migrations/2025_10_22_000400_add_last_used_to_refresh_tokens.php delete mode 100644 database/seeders/OAuthClientSeeder.php delete mode 100644 tests/Feature/Api/Tenant/TenantTokenGuardTest.php delete mode 100644 tests/Feature/OAuth/AuthorizeTest.php delete mode 100644 tests/Feature/OAuthFlowTest.php delete mode 100644 tests/e2e/oauth-flow.test.ts diff --git a/app/Filament/Resources/OAuthClientResource.php b/app/Filament/Resources/OAuthClientResource.php deleted file mode 100644 index 35a77cc..0000000 --- a/app/Filament/Resources/OAuthClientResource.php +++ /dev/null @@ -1,190 +0,0 @@ -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'), - ]; - } -} diff --git a/app/Filament/Resources/OAuthClientResource/Pages/CreateOAuthClient.php b/app/Filament/Resources/OAuthClientResource/Pages/CreateOAuthClient.php deleted file mode 100644 index 3d90754..0000000 --- a/app/Filament/Resources/OAuthClientResource/Pages/CreateOAuthClient.php +++ /dev/null @@ -1,17 +0,0 @@ -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}'), - ]; - } -} diff --git a/app/Filament/Resources/RefreshTokenResource/Pages/ListRefreshTokens.php b/app/Filament/Resources/RefreshTokenResource/Pages/ListRefreshTokens.php deleted file mode 100644 index 1d16109..0000000 --- a/app/Filament/Resources/RefreshTokenResource/Pages/ListRefreshTokens.php +++ /dev/null @@ -1,17 +0,0 @@ -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')); - }), - ]; - } -} - diff --git a/app/Filament/Resources/RefreshTokenResource/RelationManagers/AuditsRelationManager.php b/app/Filament/Resources/RefreshTokenResource/RelationManagers/AuditsRelationManager.php deleted file mode 100644 index 60b34f9..0000000 --- a/app/Filament/Resources/RefreshTokenResource/RelationManagers/AuditsRelationManager.php +++ /dev/null @@ -1,69 +0,0 @@ -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')); - } -} diff --git a/app/Http/Controllers/Api/Tenant/TenantAdminTokenController.php b/app/Http/Controllers/Api/Tenant/TenantAdminTokenController.php index dba861e..8470c51 100644 --- a/app/Http/Controllers/Api/Tenant/TenantAdminTokenController.php +++ b/app/Http/Controllers/Api/Tenant/TenantAdminTokenController.php @@ -109,6 +109,49 @@ class TenantAdminTokenController extends Controller ]); } + public function legacyTenantMe(Request $request): JsonResponse + { + /** @var Tenant|null $tenant */ + $tenant = $request->attributes->get('tenant') + ?? $request->user()?->tenant; + + if (! $tenant) { + return response()->json([ + 'error' => 'tenant_not_found', + 'message' => 'Tenant context missing.', + ], 404); + } + + $tenant->loadMissing('activeResellerPackage'); + + $user = $request->user(); + $abilities = $user?->currentAccessToken()?->abilities ?? []; + + $fullName = null; + if ($user) { + $first = trim((string) ($user->first_name ?? '')); + $last = trim((string) ($user->last_name ?? '')); + $fullName = trim($first.' '.$last) ?: null; + } + + $activePackage = $tenant->activeResellerPackage; + + return response()->json([ + 'id' => $tenant->id, + 'tenant_id' => $tenant->id, + 'name' => $tenant->name, + 'slug' => $tenant->slug, + 'email' => $tenant->contact_email, + 'fullName' => $fullName, + 'event_credits_balance' => $tenant->event_credits_balance, + 'active_reseller_package_id' => $activePackage?->id, + 'remaining_events' => $activePackage?->remaining_events ?? 0, + 'package_expires_at' => $activePackage?->expires_at, + 'features' => $tenant->features ?? [], + 'scopes' => $abilities, + ]); + } + public function exchange(Request $request): JsonResponse { /** @var User|null $user */ diff --git a/app/Http/Controllers/Auth/AuthenticatedSessionController.php b/app/Http/Controllers/Auth/AuthenticatedSessionController.php index 8676cd4..a9622b7 100644 --- a/app/Http/Controllers/Auth/AuthenticatedSessionController.php +++ b/app/Http/Controllers/Auth/AuthenticatedSessionController.php @@ -209,7 +209,7 @@ class AuthenticatedSessionController extends Controller $path = '/'.$path; } - if (str_starts_with($path, '/event-admin') || str_starts_with($path, '/api/v1/oauth/authorize')) { + if (str_starts_with($path, '/event-admin')) { return $hasScheme ? $target : $path; } diff --git a/app/Models/OAuthClient.php b/app/Models/OAuthClient.php deleted file mode 100644 index e3748d7..0000000 --- a/app/Models/OAuthClient.php +++ /dev/null @@ -1,39 +0,0 @@ - '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); - } -} - diff --git a/app/Models/OAuthCode.php b/app/Models/OAuthCode.php deleted file mode 100644 index 18c9b13..0000000 --- a/app/Models/OAuthCode.php +++ /dev/null @@ -1,47 +0,0 @@ - '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(); - } -} diff --git a/app/Models/RefreshToken.php b/app/Models/RefreshToken.php deleted file mode 100644 index d8403b6..0000000 --- a/app/Models/RefreshToken.php +++ /dev/null @@ -1,120 +0,0 @@ - '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(), - ]); - } -} diff --git a/app/Models/RefreshTokenAudit.php b/app/Models/RefreshTokenAudit.php deleted file mode 100644 index 1c31768..0000000 --- a/app/Models/RefreshTokenAudit.php +++ /dev/null @@ -1,46 +0,0 @@ - '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'); - } -} diff --git a/app/Models/TenantToken.php b/app/Models/TenantToken.php deleted file mode 100644 index 0531a45..0000000 --- a/app/Models/TenantToken.php +++ /dev/null @@ -1,51 +0,0 @@ - '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(); - } -} diff --git a/app/Policies/OAuthClientPolicy.php b/app/Policies/OAuthClientPolicy.php deleted file mode 100644 index 4580dcc..0000000 --- a/app/Policies/OAuthClientPolicy.php +++ /dev/null @@ -1,38 +0,0 @@ -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'; - } -} - diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 4fe4169..6a51dc0 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -128,10 +128,6 @@ class AppServiceProvider extends ServiceProvider return Limit::perMinute(100)->by($key); }); - RateLimiter::for('oauth', function (Request $request) { - return Limit::perMinute(10)->by('oauth:'.($request->ip() ?? 'unknown')); - }); - RateLimiter::for('tenant-auth', function (Request $request) { return Limit::perMinute(20)->by('tenant-auth:'.($request->ip() ?? 'unknown')); }); diff --git a/app/Providers/AuthServiceProvider.php b/app/Providers/AuthServiceProvider.php index bd02661..175d7ed 100644 --- a/app/Providers/AuthServiceProvider.php +++ b/app/Providers/AuthServiceProvider.php @@ -2,11 +2,9 @@ namespace App\Providers; -use App\Models\OAuthClient; use App\Models\PurchaseHistory; use App\Models\Tenant; use App\Models\User; -use App\Policies\OAuthClientPolicy; use App\Policies\PurchaseHistoryPolicy; use App\Policies\TenantPolicy; use Illuminate\Foundation\Support\Providers\AuthServiceProvider as ServiceProvider; @@ -22,7 +20,6 @@ class AuthServiceProvider extends ServiceProvider protected $policies = [ Tenant::class => TenantPolicy::class, PurchaseHistory::class => PurchaseHistoryPolicy::class, - OAuthClient::class => OAuthClientPolicy::class, ]; /** @@ -37,4 +34,3 @@ class AuthServiceProvider extends ServiceProvider }); } } - diff --git a/app/Support/TenantAuth.php b/app/Support/TenantAuth.php index 166705d..56c3e9e 100644 --- a/app/Support/TenantAuth.php +++ b/app/Support/TenantAuth.php @@ -5,7 +5,6 @@ namespace App\Support; use App\Models\User; use Illuminate\Database\Eloquent\ModelNotFoundException; use Illuminate\Http\Request; -use Illuminate\Support\Arr; class TenantAuth { @@ -16,24 +15,17 @@ class TenantAuth */ public static function resolveAdminUser(Request $request): User { - $decoded = (array) $request->attributes->get('decoded_token', []); $tenantId = $request->attributes->get('tenant_id') ?? $request->input('tenant_id') - ?? Arr::get($decoded, 'tenant_id'); + ?? $request->user()?->tenant_id; if (! $tenantId) { throw (new ModelNotFoundException)->setModel(User::class); } - $userId = Arr::get($decoded, 'user_id'); - - if ($userId) { - $user = User::query() - ->whereKey($userId) - ->where('tenant_id', $tenantId) - ->first(); - - if ($user) { + $user = $request->user(); + if ($user && in_array($user->role, ['tenant_admin', 'admin', 'super_admin'], true)) { + if ($user->role !== 'super_admin' || (int) $user->tenant_id === (int) $tenantId) { return $user; } } diff --git a/bootstrap/app.php b/bootstrap/app.php index 6016cdb..9779bc8 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -6,7 +6,6 @@ use App\Http\Middleware\HandleAppearance; use App\Http\Middleware\HandleInertiaRequests; use App\Http\Middleware\SetLocaleFromUser; use App\Http\Middleware\TenantIsolation; -use App\Http\Middleware\TenantTokenGuard; use Illuminate\Foundation\Application; use Illuminate\Foundation\Configuration\Exceptions; use Illuminate\Foundation\Configuration\Middleware; diff --git a/config/oauth.php b/config/oauth.php deleted file mode 100644 index a81cb3b..0000000 --- a/config/oauth.php +++ /dev/null @@ -1,14 +0,0 @@ - [ - '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), - ], -]; diff --git a/config/services.php b/config/services.php index 34210ab..9870978 100644 --- a/config/services.php +++ b/config/services.php @@ -63,27 +63,4 @@ return [ 'queue' => env('REVENUECAT_WEBHOOK_QUEUE', 'webhooks'), ], - 'oauth' => [ - 'tenant_admin' => [ - 'id' => env('VITE_OAUTH_CLIENT_ID', 'tenant-admin-app'), - 'redirects' => (function (): array { - $redirects = []; - - $devServer = env('VITE_DEV_SERVER_URL'); - $redirects[] = rtrim($devServer ?: 'http://localhost:5173', '/').'/event-admin/auth/callback'; - - $appUrl = env('APP_URL'); - if ($appUrl) { - $redirects[] = rtrim($appUrl, '/').'/event-admin/auth/callback'; - } else { - $redirects[] = 'http://localhost:8000/event-admin/auth/callback'; - } - - $extra = array_filter(array_map('trim', explode(',', (string) env('TENANT_ADMIN_OAUTH_REDIRECTS', '')))); - - return array_values(array_unique(array_filter(array_merge($redirects, $extra)))); - })(), - ], - ], - ]; diff --git a/database/migrations/2025_10_22_000300_create_refresh_token_audits_table.php b/database/migrations/2025_10_22_000300_create_refresh_token_audits_table.php deleted file mode 100644 index 9dbec79..0000000 --- a/database/migrations/2025_10_22_000300_create_refresh_token_audits_table.php +++ /dev/null @@ -1,34 +0,0 @@ -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'); - } -}; diff --git a/database/migrations/2025_10_22_000400_add_last_used_to_refresh_tokens.php b/database/migrations/2025_10_22_000400_add_last_used_to_refresh_tokens.php deleted file mode 100644 index fd64f67..0000000 --- a/database/migrations/2025_10_22_000400_add_last_used_to_refresh_tokens.php +++ /dev/null @@ -1,34 +0,0 @@ -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'); - } - }); - } -}; diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php index 652a2cf..8c89d2e 100644 --- a/database/seeders/DatabaseSeeder.php +++ b/database/seeders/DatabaseSeeder.php @@ -33,7 +33,6 @@ class DatabaseSeeder extends Seeder SuperAdminSeeder::class, DemoTenantSeeder::class, DemoEventSeeder::class, - OAuthClientSeeder::class, ]); if (app()->environment(['local', 'development', 'demo'])) { diff --git a/database/seeders/OAuthClientSeeder.php b/database/seeders/OAuthClientSeeder.php deleted file mode 100644 index e43db95..0000000 --- a/database/seeders/OAuthClientSeeder.php +++ /dev/null @@ -1,53 +0,0 @@ -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(); - } -} diff --git a/database/seeders/_DemoLifecycleSeeder.php b/database/seeders/_DemoLifecycleSeeder.php index 1fb9887..284c2e3 100644 --- a/database/seeders/_DemoLifecycleSeeder.php +++ b/database/seeders/_DemoLifecycleSeeder.php @@ -5,7 +5,6 @@ namespace Database\Seeders; use App\Models\Event; use App\Models\EventPackage; use App\Models\EventType; -use App\Models\OAuthClient; use App\Models\Package; use App\Models\PackagePurchase; use App\Models\Tenant; @@ -125,7 +124,6 @@ class DemoLifecycleSeeder extends Seeder ]); $this->createTenantAdmin($tenant, 'storycraft-owner@demo.fotospiel'); - $this->ensureOAuthClientForTenant($tenant, 'demo-tenant-admin-storycraft'); } private function seedActiveTenant(Package $standard, Package $premium, EventType $weddingType, EventType $corporateType): void @@ -139,12 +137,7 @@ class DemoLifecycleSeeder extends Seeder 'is_active' => true, ]); - OAuthClient::query() - ->where('client_id', config('services.oauth.tenant_admin.id', 'tenant-admin-app')) - ->update(['tenant_id' => $tenant->id]); - $this->createTenantAdmin($tenant, 'hello@lumen-moments.demo'); - $this->ensureOAuthClientForTenant($tenant, 'demo-tenant-admin-lumen'); $purchase = PackagePurchase::create([ 'tenant_id' => $tenant->id, @@ -210,7 +203,6 @@ class DemoLifecycleSeeder extends Seeder ]); $this->createTenantAdmin($tenant, 'team@viewfinder.demo'); - $this->ensureOAuthClientForTenant($tenant, 'demo-tenant-admin-viewfinder'); $tenantPackage = TenantPackage::create([ 'tenant_id' => $tenant->id, @@ -280,7 +272,6 @@ class DemoLifecycleSeeder extends Seeder ]); $this->createTenantAdmin($tenant, 'support@pixelco.demo', role: 'member'); - $this->ensureOAuthClientForTenant($tenant, 'demo-tenant-admin-pixel'); } private function createTenantAdmin(Tenant $tenant, string $email, string $role = 'tenant_admin'): User @@ -379,30 +370,4 @@ class DemoLifecycleSeeder extends Seeder ]; } - private function ensureOAuthClientForTenant(Tenant $tenant, string $clientId): void - { - $redirectUris = config('services.oauth.tenant_admin.redirects', []); - if (empty($redirectUris)) { - $redirectUris = [ - 'http://localhost:5173/event-admin/auth/callback', - url('/event-admin/auth/callback'), - ]; - } - - $client = OAuthClient::firstOrNew(['client_id' => $clientId]); - - if (! $client->exists) { - $client->id = (string) Str::uuid(); - } - - $client->fill([ - 'client_secret' => null, - 'tenant_id' => $tenant->id, - 'redirect_uris' => $redirectUris, - 'scopes' => ['tenant:read', 'tenant:write'], - 'is_active' => true, - ]); - - $client->save(); - } } diff --git a/resources/lang/de/admin.php b/resources/lang/de/admin.php index 44e34f9..6f3f0dd 100644 --- a/resources/lang/de/admin.php +++ b/resources/lang/de/admin.php @@ -303,110 +303,6 @@ return [ 'export_success' => 'Export abgeschlossen. :count Einträge exportiert.', ], - 'oauth' => [ - 'fields' => [ - 'name' => 'Name', - 'client_id' => 'Client-ID', - 'client_secret' => 'Client-Secret', - 'tenant' => 'Mandant', - 'redirect_uris' => 'Redirect-URIs', - 'scopes' => 'Scopes', - 'is_active' => 'Aktiv', - 'description' => 'Beschreibung', - 'updated_at' => 'Zuletzt geändert', - ], - 'hints' => [ - 'client_secret' => 'Leer lassen, um das bestehende Secret zu behalten oder für PKCE-Clients ohne Secret.', - 'redirect_uris' => 'Eine URL pro Zeile. Die Callback-URL muss exakt übereinstimmen.', - ], - 'filters' => [ - 'is_active' => 'Status', - 'any' => 'Alle', - 'active' => 'Aktiv', - 'inactive' => 'Inaktiv', - ], - 'actions' => [ - 'regenerate_secret' => 'Secret neu generieren', - ], - 'notifications' => [ - 'secret_regenerated_title' => 'Neues Secret erstellt', - 'secret_regenerated_body' => 'Speichere das neue Secret sicher: :secret', - 'created_title' => 'OAuth-Client erstellt', - 'updated_title' => 'OAuth-Client gespeichert', - ], - ], - - 'refresh_tokens' => [ - 'menu' => 'Refresh Tokens', - 'single' => 'Refresh Token', - 'fields' => [ - 'tenant' => 'Mandant', - 'client' => 'Client', - 'status' => 'Status', - 'revoked_reason' => 'Widerrufsgrund', - 'created_at' => 'Erstellt', - 'last_used_at' => 'Zuletzt verwendet', - 'expires_at' => 'Gültig bis', - 'ip_address' => 'IP-Adresse', - 'user_agent' => 'User Agent', - 'note' => 'Notiz', - ], - 'status' => [ - 'active' => 'Aktiv', - 'revoked' => 'Widerrufen', - 'expired' => 'Abgelaufen', - ], - 'filters' => [ - 'status' => 'Status', - 'tenant' => 'Mandant', - ], - 'actions' => [ - 'revoke' => 'Token widerrufen', - ], - 'reasons' => [ - 'manual' => 'Manuell', - 'operator' => 'Operator-Aktion', - 'rotated' => 'Automatisch rotiert', - 'ip_mismatch' => 'IP-Abweichung', - 'expired' => 'Abgelaufen', - 'invalid_secret' => 'Ungültiges Secret', - 'tenant_missing' => 'Mandant entfernt', - 'max_active_limit' => 'Maximale Anzahl überschritten', - ], - 'sections' => [ - 'details' => 'Token-Details', - 'security' => 'Sicherheitskontext', - ], - 'audit' => [ - 'heading' => 'Audit-Log', - 'event' => 'Ereignis', - 'events' => [ - 'issued' => 'Ausgestellt', - 'refresh_attempt' => 'Refresh versucht', - 'refreshed' => 'Refresh erfolgreich', - 'client_mismatch' => 'Client stimmt nicht überein', - 'invalid_secret' => 'Ungültiges Secret', - 'ip_mismatch' => 'IP-Abweichung', - 'expired' => 'Abgelaufen', - 'revoked' => 'Widerrufen', - 'rotated' => 'Rotiert', - 'tenant_missing' => 'Mandant fehlt', - 'max_active_limit' => 'Begrenzung erreicht', - ], - 'performed_by' => 'Ausgeführt von', - 'ip_address' => 'IP-Adresse', - 'context' => 'Kontext', - 'performed_at' => 'Zeitpunkt', - 'empty' => [ - 'heading' => 'Noch keine Einträge', - 'description' => 'Sobald das Token verwendet wird, erscheinen hier Einträge.', - ], - ], - 'notifications' => [ - 'revoked' => 'Refresh Token wurde widerrufen.', - ], - ], - 'shell' => [ 'tenant_admin_title' => 'Tenant‑Admin', ], diff --git a/resources/lang/en/admin.php b/resources/lang/en/admin.php index b896789..4904fcd 100644 --- a/resources/lang/en/admin.php +++ b/resources/lang/en/admin.php @@ -289,110 +289,6 @@ return [ 'export_success' => 'Export ready. :count rows exported.', ], - 'oauth' => [ - 'fields' => [ - 'name' => 'Name', - 'client_id' => 'Client ID', - 'client_secret' => 'Client secret', - 'tenant' => 'Tenant', - 'redirect_uris' => 'Redirect URIs', - 'scopes' => 'Scopes', - 'is_active' => 'Active', - 'description' => 'Description', - 'updated_at' => 'Last updated', - ], - 'hints' => [ - 'client_secret' => 'Leave blank to keep the current secret or for PKCE/public clients.', - 'redirect_uris' => 'One URL per line. Must exactly match the callback on the client.', - ], - 'filters' => [ - 'is_active' => 'Status', - 'any' => 'All', - 'active' => 'Active', - 'inactive' => 'Inactive', - ], - 'actions' => [ - 'regenerate_secret' => 'Regenerate secret', - ], - 'notifications' => [ - 'secret_regenerated_title' => 'New secret generated', - 'secret_regenerated_body' => 'Store the new secret securely: :secret', - 'created_title' => 'OAuth client created', - 'updated_title' => 'OAuth client saved', - ], - ], - - 'refresh_tokens' => [ - 'menu' => 'Refresh tokens', - 'single' => 'Refresh token', - 'fields' => [ - 'tenant' => 'Tenant', - 'client' => 'Client', - 'status' => 'Status', - 'revoked_reason' => 'Revoked reason', - 'created_at' => 'Created', - 'last_used_at' => 'Last used', - 'expires_at' => 'Expires at', - 'ip_address' => 'IP address', - 'user_agent' => 'User agent', - 'note' => 'Operator note', - ], - 'status' => [ - 'active' => 'Active', - 'revoked' => 'Revoked', - 'expired' => 'Expired', - ], - 'filters' => [ - 'status' => 'Status', - 'tenant' => 'Tenant', - ], - 'actions' => [ - 'revoke' => 'Revoke token', - ], - 'reasons' => [ - 'manual' => 'Manual', - 'operator' => 'Operator action', - 'rotated' => 'Rotated (auto)', - 'ip_mismatch' => 'IP mismatch', - 'expired' => 'Expired', - 'invalid_secret' => 'Invalid secret attempt', - 'tenant_missing' => 'Tenant removed', - 'max_active_limit' => 'Exceeded active token limit', - ], - 'sections' => [ - 'details' => 'Token details', - 'security' => 'Security context', - ], - 'audit' => [ - 'heading' => 'Audit log', - 'event' => 'Event', - 'events' => [ - 'issued' => 'Issued', - 'refresh_attempt' => 'Refresh attempted', - 'refreshed' => 'Refresh succeeded', - 'client_mismatch' => 'Client mismatch', - 'invalid_secret' => 'Invalid secret', - 'ip_mismatch' => 'IP mismatch', - 'expired' => 'Expired', - 'revoked' => 'Revoked', - 'rotated' => 'Rotated', - 'tenant_missing' => 'Tenant missing', - 'max_active_limit' => 'Pruned (active limit)', - ], - 'performed_by' => 'Actor', - 'ip_address' => 'IP address', - 'context' => 'Context', - 'performed_at' => 'Timestamp', - 'empty' => [ - 'heading' => 'No audit entries yet', - 'description' => 'Token activity will appear here once it is used.', - ], - ], - 'notifications' => [ - 'revoked' => 'Refresh token revoked.', - ], - ], - 'shell' => [ 'tenant_admin_title' => 'Tenant Admin', ], diff --git a/routes/api.php b/routes/api.php index 747efca..1c176e4 100644 --- a/routes/api.php +++ b/routes/api.php @@ -19,7 +19,6 @@ use App\Http\Controllers\Api\Tenant\TenantAdminTokenController; use App\Http\Controllers\Api\Tenant\TenantFeedbackController; use App\Http\Controllers\Api\TenantBillingController; use App\Http\Controllers\Api\TenantPackageController; -use App\Http\Controllers\OAuthController; use App\Http\Controllers\RevenueCatWebhookController; use App\Http\Controllers\Tenant\CreditController; use Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse; @@ -32,11 +31,6 @@ Route::prefix('v1')->name('api.v1.')->group(function () { ->middleware('throttle:60,1') ->name('webhooks.revenuecat'); - Route::middleware([EncryptCookies::class, AddQueuedCookiesToResponse::class, StartSession::class, 'throttle:oauth'])->group(function () { - Route::get('/oauth/authorize', [OAuthController::class, 'authorize'])->name('oauth.authorize'); - Route::post('/oauth/token', [OAuthController::class, 'token'])->name('oauth.token'); - }); - Route::prefix('tenant-auth')->name('tenant-auth.')->group(function () { Route::post('/login', [TenantAdminTokenController::class, 'store']) ->middleware('throttle:tenant-auth') @@ -84,7 +78,7 @@ Route::prefix('v1')->name('api.v1.')->group(function () { Route::put('profile', [ProfileController::class, 'update'])->name('tenant.profile.update'); Route::get('onboarding', [OnboardingController::class, 'show'])->name('tenant.onboarding.show'); Route::post('onboarding', [OnboardingController::class, 'store'])->name('tenant.onboarding.store'); - Route::get('me', [OAuthController::class, 'me'])->name('tenant.me'); + Route::get('me', [TenantAdminTokenController::class, 'legacyTenantMe'])->name('tenant.me'); Route::get('dashboard', DashboardController::class)->name('tenant.dashboard'); Route::get('event-types', EventTypeController::class)->name('tenant.event-types.index'); diff --git a/tests/Feature/Api/Tenant/TenantTokenGuardTest.php b/tests/Feature/Api/Tenant/TenantTokenGuardTest.php deleted file mode 100644 index 39b1ccc..0000000 --- a/tests/Feature/Api/Tenant/TenantTokenGuardTest.php +++ /dev/null @@ -1,26 +0,0 @@ -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); - } -} diff --git a/tests/Feature/Auth/LoginTest.php b/tests/Feature/Auth/LoginTest.php index 26df96e..aefa3ed 100644 --- a/tests/Feature/Auth/LoginTest.php +++ b/tests/Feature/Auth/LoginTest.php @@ -120,7 +120,7 @@ class LoginTest extends TestCase 'email_verified_at' => now(), ]); - $intended = 'http://localhost/api/v1/oauth/authorize?client_id=tenant-admin-app&response_type=code'; + $intended = 'http://localhost/event-admin/dashboard?from=intended-test'; $returnTarget = '/event-admin/dashboard'; $encodedReturn = rtrim(strtr(base64_encode($returnTarget), '+/', '-_'), '='); diff --git a/tests/Feature/Auth/TenantAdminGoogleControllerTest.php b/tests/Feature/Auth/TenantAdminGoogleControllerTest.php index 8678adb..681e581 100644 --- a/tests/Feature/Auth/TenantAdminGoogleControllerTest.php +++ b/tests/Feature/Auth/TenantAdminGoogleControllerTest.php @@ -56,7 +56,7 @@ class TenantAdminGoogleControllerTest extends TestCase Socialite::shouldReceive('driver')->once()->with('google')->andReturn($driver); $driver->shouldReceive('user')->once()->andReturn($socialiteUser); - $targetUrl = 'http://localhost:8000/api/v1/oauth/authorize?foo=bar'; + $targetUrl = 'http://localhost:8000/event-admin/dashboard?foo=bar'; $encodedReturn = rtrim(strtr(base64_encode($targetUrl), '+/', '-_'), '='); $this->withSession([ diff --git a/tests/Feature/Auth/TenantProfileApiTest.php b/tests/Feature/Auth/TenantProfileApiTest.php index 093c740..f24947e 100644 --- a/tests/Feature/Auth/TenantProfileApiTest.php +++ b/tests/Feature/Auth/TenantProfileApiTest.php @@ -27,6 +27,8 @@ class TenantProfileApiTest extends TestCase 'password' => Hash::make('secret-password'), 'email' => 'tenant@example.com', 'name' => 'Max Mustermann', + 'first_name' => 'Max', + 'last_name' => 'Mustermann', ]); $login = $this->postJson('/api/v1/tenant-auth/login', [ @@ -57,6 +59,34 @@ class TenantProfileApiTest extends TestCase $data = $me->json(); $this->assertEquals('Max Mustermann', data_get($data, 'user.name')); $this->assertContains('tenant-admin', $data['abilities']); + + $legacy = $this + ->withHeader('Authorization', 'Bearer '.$token) + ->getJson('/api/v1/tenant/me'); + + $legacy->assertOk(); + $legacy->assertJsonFragment([ + 'id' => $tenant->id, + 'tenant_id' => $tenant->id, + 'name' => 'Test Tenant GmbH', + 'event_credits_balance' => 12, + 'fullName' => 'Max Mustermann', + ]); + $legacy->assertJsonStructure([ + 'id', + 'tenant_id', + 'name', + 'slug', + 'email', + 'fullName', + 'event_credits_balance', + 'active_reseller_package_id', + 'remaining_events', + 'package_expires_at', + 'features', + 'scopes', + ]); + $this->assertContains('tenant-admin', $legacy->json('scopes')); } public function test_me_requires_valid_token(): void diff --git a/tests/Feature/OAuth/AuthorizeTest.php b/tests/Feature/OAuth/AuthorizeTest.php deleted file mode 100644 index c30642c..0000000 --- a/tests/Feature/OAuth/AuthorizeTest.php +++ /dev/null @@ -1,204 +0,0 @@ -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); - } -} diff --git a/tests/Feature/OAuthFlowTest.php b/tests/Feature/OAuthFlowTest.php deleted file mode 100644 index ca3befe..0000000 --- a/tests/Feature/OAuthFlowTest.php +++ /dev/null @@ -1,284 +0,0 @@ -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', - ]; - } -} - diff --git a/tests/Feature/Tenant/TenantTestCase.php b/tests/Feature/Tenant/TenantTestCase.php index 4b5cb2d..6da97ab 100644 --- a/tests/Feature/Tenant/TenantTestCase.php +++ b/tests/Feature/Tenant/TenantTestCase.php @@ -2,10 +2,10 @@ namespace Tests\Feature\Tenant; -use App\Models\OAuthClient; use App\Models\Tenant; use App\Models\User; use Illuminate\Foundation\Testing\RefreshDatabase; +use Illuminate\Support\Facades\Hash; use Illuminate\Support\Str; use Tests\TestCase; @@ -17,12 +17,8 @@ abstract class TenantTestCase extends TestCase protected User $tenantUser; - protected OAuthClient $oauthClient; - protected string $token; - protected ?string $refreshToken = null; - protected function setUp(): void { parent::setUp(); @@ -44,7 +40,7 @@ abstract class TenantTestCase extends TestCase $this->app->instance('tenant', $this->tenant); } - protected function initialiseTenantContext(array $scopes = ['tenant:read', 'tenant:write']): void + protected function initialiseTenantContext(): void { $this->tenant = Tenant::factory()->create([ 'name' => 'Test Tenant', @@ -55,68 +51,18 @@ abstract class TenantTestCase extends TestCase 'name' => 'Test User', 'email' => 'test-'.Str::random(6).'@example.com', 'tenant_id' => $this->tenant->id, - 'role' => 'admin', + 'role' => 'tenant_admin', + 'password' => Hash::make('password'), ]); - $this->oauthClient = $this->createTenantClient($this->tenant, $scopes); - [$this->token, $this->refreshToken] = $this->issueTokens($this->oauthClient, $scopes); + $login = $this->postJson('/api/v1/tenant-auth/login', [ + 'login' => $this->tenantUser->email, + 'password' => 'password', + ]); + + $login->assertOk(); + $this->token = (string) $login->json('token'); $this->app->instance('tenant', $this->tenant); } - - protected function createTenantClient(Tenant $tenant, array $scopes): OAuthClient - { - return OAuthClient::create([ - 'id' => (string) Str::uuid(), - 'client_id' => 'tenant-admin-app-'.$tenant->id, - 'tenant_id' => $tenant->id, - 'client_secret' => null, - 'redirect_uris' => ['http://localhost/callback'], - 'scopes' => $scopes, - 'is_active' => true, - ]); - } - - protected function issueTokens(OAuthClient $client, array $scopes = ['tenant:read', 'tenant:write']): array - { - $this->actingAs($this->tenantUser); - - $codeVerifier = 'tenant-code-verifier-'.Str::random(32); - $codeChallenge = rtrim(strtr(base64_encode(hash('sha256', $codeVerifier, true)), '+/', '-_'), '='); - $state = Str::random(10); - - $response = $this->get('/api/v1/oauth/authorize?'.http_build_query([ - 'client_id' => $client->client_id, - 'redirect_uri' => 'http://localhost/callback', - 'response_type' => 'code', - 'scope' => implode(' ', $scopes), - 'state' => $state, - 'code_challenge' => $codeChallenge, - 'code_challenge_method' => 'S256', - ])); - - $response->assertRedirect(); - $location = $response->headers->get('Location'); - $this->assertNotNull($location); - - $query = []; - parse_str(parse_url($location, PHP_URL_QUERY) ?? '', $query); - $authorizationCode = $query['code'] ?? null; - $this->assertNotNull($authorizationCode, 'Authorization code should be present'); - - $tokenResponse = $this->post('/api/v1/oauth/token', [ - 'grant_type' => 'authorization_code', - 'code' => $authorizationCode, - 'client_id' => $client->client_id, - 'redirect_uri' => 'http://localhost/callback', - 'code_verifier' => $codeVerifier, - ]); - - $tokenResponse->assertOk(); - - return [ - $tokenResponse->json('access_token'), - $tokenResponse->json('refresh_token'), - ]; - } } diff --git a/tests/Feature/TenantCreditsTest.php b/tests/Feature/TenantCreditsTest.php index 2ccbc01..1fbd218 100644 --- a/tests/Feature/TenantCreditsTest.php +++ b/tests/Feature/TenantCreditsTest.php @@ -2,10 +2,10 @@ namespace Tests\Feature; -use App\Models\OAuthClient; use App\Models\Tenant; +use App\Models\User; use Illuminate\Foundation\Testing\RefreshDatabase; -use Illuminate\Support\Str; +use Illuminate\Support\Facades\Hash; use Tests\TestCase; class TenantCreditsTest extends TestCase @@ -19,16 +19,21 @@ class TenantCreditsTest extends TestCase 'event_credits_balance' => 0, ]); - $client = OAuthClient::create([ - 'id' => (string) Str::uuid(), - 'client_id' => 'tenant-admin-app', + $user = User::factory()->create([ 'tenant_id' => $tenant->id, - 'redirect_uris' => ['http://localhost/callback'], - 'scopes' => ['tenant:read', 'tenant:write'], - 'is_active' => true, + 'role' => 'tenant_admin', + 'email' => 'tenant-admin@example.com', + 'password' => Hash::make('password'), ]); - [$accessToken] = $this->obtainTokens($client); + $login = $this->postJson('/api/v1/tenant-auth/login', [ + 'login' => $user->email, + 'password' => 'password', + ]); + + $login->assertOk(); + + $accessToken = $login->json('token'); $headers = [ 'Authorization' => 'Bearer '.$accessToken, @@ -77,46 +82,5 @@ class TenantCreditsTest extends TestCase $syncResponse->assertOk() ->assertJsonStructure(['balance', 'subscription_active', 'server_time']); } - - private function obtainTokens(OAuthClient $client): array - { - $codeVerifier = 'tenant-credits-code-verifier-1234567890'; - $codeChallenge = rtrim(strtr(base64_encode(hash('sha256', $codeVerifier, true)), '+/', '-_'), '='); - $state = Str::random(10); - - $response = $this->get('/api/v1/oauth/authorize?' . http_build_query([ - 'client_id' => $client->client_id, - 'redirect_uri' => 'http://localhost/callback', - 'response_type' => 'code', - 'scope' => 'tenant:read tenant:write', - 'state' => $state, - 'code_challenge' => $codeChallenge, - 'code_challenge_method' => 'S256', - ])); - - $response->assertRedirect(); - $location = $response->headers->get('Location'); - $this->assertNotNull($location); - - $query = []; - parse_str(parse_url($location, PHP_URL_QUERY) ?? '', $query); - $authorizationCode = $query['code'] ?? null; - $this->assertNotNull($authorizationCode, 'Authorization code should be present'); - - $tokenResponse = $this->post('/api/v1/oauth/token', [ - 'grant_type' => 'authorization_code', - 'code' => $authorizationCode, - 'client_id' => $client->client_id, - 'redirect_uri' => 'http://localhost/callback', - 'code_verifier' => $codeVerifier, - ]); - - $tokenResponse->assertOk(); - - return [ - $tokenResponse->json('access_token'), - $tokenResponse->json('refresh_token'), - ]; - } } diff --git a/tests/e2e/oauth-flow.test.ts b/tests/e2e/oauth-flow.test.ts deleted file mode 100644 index 3050c05..0000000 --- a/tests/e2e/oauth-flow.test.ts +++ /dev/null @@ -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'); -}); \ No newline at end of file diff --git a/tests/e2e/utils/test-fixtures.ts b/tests/e2e/utils/test-fixtures.ts index 1e58e97..49f742f 100644 --- a/tests/e2e/utils/test-fixtures.ts +++ b/tests/e2e/utils/test-fixtures.ts @@ -1,6 +1,5 @@ import 'dotenv/config'; import { test as base, expect, Page, APIRequestContext } from '@playwright/test'; -import { randomBytes, createHash } from 'node:crypto'; export type TenantCredentials = { email: string; @@ -44,16 +43,13 @@ export const test = base.extend({ export const expectFixture = expect; -const clientId = process.env.VITE_OAUTH_CLIENT_ID ?? 'tenant-admin-app'; -const redirectUri = new URL('/event-admin/auth/callback', process.env.PLAYWRIGHT_BASE_URL ?? 'http://localhost:8000').toString(); -const scopes = (process.env.VITE_OAUTH_SCOPES as string | undefined) ?? 'tenant:read tenant:write'; - -async function performTenantSignIn(page: Page, _credentials: TenantCredentials) { - const tokens = await exchangeTokens(page.request); +async function performTenantSignIn(page: Page, credentials: TenantCredentials) { + const token = await exchangeToken(page.request, credentials); await page.addInitScript(({ stored }) => { - localStorage.setItem('tenant_oauth_tokens.v1', JSON.stringify(stored)); - }, { stored: tokens }); + localStorage.setItem('tenant_admin.token.v1', JSON.stringify(stored)); + sessionStorage.setItem('tenant_admin.token.session.v1', JSON.stringify(stored)); + }, { stored: token }); await page.goto('/event-admin'); await page.waitForLoadState('domcontentloaded'); @@ -61,78 +57,27 @@ async function performTenantSignIn(page: Page, _credentials: TenantCredentials) type StoredTokenPayload = { accessToken: string; - refreshToken: string; - expiresAt: number; - scope?: string; - clientId?: string; + abilities: string[]; + issuedAt: number; }; -async function exchangeTokens(request: APIRequestContext): Promise { - const verifier = generateCodeVerifier(); - const challenge = generateCodeChallenge(verifier); - const state = randomBytes(12).toString('hex'); - - const params = new URLSearchParams({ - response_type: 'code', - client_id: clientId, - redirect_uri: redirectUri, - scope: scopes, - state, - code_challenge: challenge, - code_challenge_method: 'S256', - }); - - const authResponse = await request.get(`/api/v1/oauth/authorize?${params.toString()}`, { - maxRedirects: 0, - headers: { - 'x-playwright-test': 'tenant-admin', +async function exchangeToken(request: APIRequestContext, credentials: TenantCredentials): Promise { + const response = await request.post('/api/v1/tenant-auth/login', { + data: { + login: credentials.email, + password: credentials.password, }, }); - if (authResponse.status() >= 400) { - throw new Error(`OAuth authorize failed: ${authResponse.status()} ${await authResponse.text()}`); + if (!response.ok()) { + throw new Error(`Tenant PAT login failed: ${response.status()} ${await response.text()}`); } - const location = authResponse.headers()['location']; - if (!location) { - throw new Error('OAuth authorize did not return redirect location'); - } - - const code = new URL(location).searchParams.get('code'); - if (!code) { - throw new Error('OAuth authorize response missing code'); - } - - const tokenResponse = await request.post('/api/v1/oauth/token', { - form: { - grant_type: 'authorization_code', - code, - client_id: clientId, - redirect_uri: redirectUri, - code_verifier: verifier, - }, - }); - - if (!tokenResponse.ok()) { - throw new Error(`OAuth token exchange failed: ${tokenResponse.status()} ${await tokenResponse.text()}`); - } - - const body = await tokenResponse.json(); - const expiresIn = typeof body.expires_in === 'number' ? body.expires_in : 3600; + const body = await response.json(); return { - accessToken: body.access_token, - refreshToken: body.refresh_token, - expiresAt: Date.now() + Math.max(expiresIn - 30, 0) * 1000, - scope: body.scope, - clientId, + accessToken: body.token, + abilities: Array.isArray(body.abilities) ? body.abilities : [], + issuedAt: Date.now(), }; } - -function generateCodeVerifier(): string { - return randomBytes(32).toString('base64url'); -} - -function generateCodeChallenge(verifier: string): string { - return createHash('sha256').update(verifier).digest('base64url'); -}