From 776da57ca97cc21f2f23c80098950993f84f2f98 Mon Sep 17 00:00:00 2001 From: Codex Agent Date: Thu, 6 Nov 2025 20:35:58 +0100 Subject: [PATCH] stage 1 of oauth removal, switch to sanctum pat tokens --- app/Console/Commands/OAuthListKeysCommand.php | 69 -- .../Commands/OAuthPruneKeysCommand.php | 77 -- .../Commands/OAuthRotateKeysCommand.php | 113 --- .../Auth/AuthenticatedSessionController.php | 169 +++- app/Http/Controllers/DashboardController.php | 32 +- app/Http/Controllers/OAuthController.php | 884 ------------------ .../Controllers/TenantAdminAuthController.php | 32 + .../TenantAdminGoogleController.php | 2 +- .../Middleware/RedirectIfAuthenticated.php | 147 +++ app/Http/Middleware/TenantIsolation.php | 6 + app/Http/Middleware/TenantTokenGuard.php | 309 ------ app/Providers/AppServiceProvider.php | 4 + bootstrap/app.php | 6 +- database/factories/UserFactory.php | 1 + .../2025_09_15_000000_create_oauth_system.php | 127 --- resources/js/admin/api.ts | 155 ++- resources/js/admin/auth/context.tsx | 223 +++-- resources/js/admin/auth/tokens.ts | 369 +++----- resources/js/admin/components/AdminLayout.tsx | 96 +- .../js/admin/components/tenant/hero-card.tsx | 35 +- resources/js/admin/components/tenant/index.ts | 3 +- resources/js/admin/dev-tools.ts | 218 +---- resources/js/admin/lib/returnTo.ts | 92 +- resources/js/admin/main.tsx | 15 +- resources/js/admin/pages/AuthCallbackPage.tsx | 52 +- resources/js/admin/pages/BillingPage.tsx | 19 +- resources/js/admin/pages/DashboardPage.tsx | 13 +- resources/js/admin/pages/EngagementPage.tsx | 13 +- resources/js/admin/pages/EventsPage.tsx | 12 +- resources/js/admin/pages/LoginPage.tsx | 321 +++---- resources/js/admin/pages/LoginStartPage.tsx | 78 +- resources/js/admin/pages/SettingsPage.tsx | 25 +- .../js/admin/pages/WelcomeTeaserPage.tsx | 11 +- resources/js/admin/router.tsx | 8 +- resources/js/components/user-info.tsx | 8 +- resources/js/hooks/use-initials.tsx | 11 +- resources/lang/de/auth.php | 9 + resources/lang/en/auth.php | 9 + routes/api.php | 20 +- routes/web.php | 8 +- tests/Feature/Auth/AuthenticationTest.php | 5 +- tests/Feature/Auth/LoginTest.php | 110 ++- tests/Feature/Auth/RoleBasedLoginTest.php | 70 ++ tests/Feature/Auth/TenantAdminEntryTest.php | 42 + tests/Feature/Auth/UserRoleAccessTest.php | 77 ++ tests/Feature/Checkout/CheckoutAuthTest.php | 16 +- tests/Feature/DashboardTest.php | 5 +- 47 files changed, 1571 insertions(+), 2555 deletions(-) delete mode 100644 app/Console/Commands/OAuthListKeysCommand.php delete mode 100644 app/Console/Commands/OAuthPruneKeysCommand.php delete mode 100644 app/Console/Commands/OAuthRotateKeysCommand.php delete mode 100644 app/Http/Controllers/OAuthController.php create mode 100644 app/Http/Controllers/TenantAdminAuthController.php create mode 100644 app/Http/Middleware/RedirectIfAuthenticated.php delete mode 100644 app/Http/Middleware/TenantTokenGuard.php delete mode 100644 database/migrations/2025_09_15_000000_create_oauth_system.php create mode 100644 tests/Feature/Auth/RoleBasedLoginTest.php create mode 100644 tests/Feature/Auth/TenantAdminEntryTest.php create mode 100644 tests/Feature/Auth/UserRoleAccessTest.php diff --git a/app/Console/Commands/OAuthListKeysCommand.php b/app/Console/Commands/OAuthListKeysCommand.php deleted file mode 100644 index f88da40..0000000 --- a/app/Console/Commands/OAuthListKeysCommand.php +++ /dev/null @@ -1,69 +0,0 @@ -error("Key store path does not exist: {$storage}"); - return self::FAILURE; - } - - $directories = collect(File::directories($storage)) - ->filter(fn ($path) => Str::lower(basename($path)) !== 'archive') - ->values() - ->map(function (string $path) use ($currentKid) { - $kid = basename($path); - $publicKey = $path.DIRECTORY_SEPARATOR.'public.key'; - $privateKey = $path.DIRECTORY_SEPARATOR.'private.key'; - - return [ - 'kid' => $kid, - 'status' => $kid === $currentKid ? 'current' : 'legacy', - 'public' => File::exists($publicKey), - 'private' => File::exists($privateKey), - 'updated_at' => File::exists($path) ? date('c', File::lastModified($path)) : null, - 'path' => $path, - ]; - }) - ->sortBy(fn ($entry) => ($entry['status'] === 'current' ? '0-' : '1-').$entry['kid']) - ->values(); - - if ($this->option('json')) { - $this->line($directories->toJson(JSON_PRETTY_PRINT)); - return self::SUCCESS; - } - - if ($directories->isEmpty()) { - $this->warn('No signing key directories found.'); - return self::SUCCESS; - } - - $this->table( - ['KID', 'Status', 'Public.key', 'Private.key', 'Updated At', 'Path'], - $directories->map(fn ($entry) => [ - $entry['kid'], - $entry['status'], - $entry['public'] ? 'yes' : 'no', - $entry['private'] ? 'yes' : 'no', - $entry['updated_at'] ?? 'n/a', - $entry['path'], - ]) - ); - - return self::SUCCESS; - } -} diff --git a/app/Console/Commands/OAuthPruneKeysCommand.php b/app/Console/Commands/OAuthPruneKeysCommand.php deleted file mode 100644 index db472b8..0000000 --- a/app/Console/Commands/OAuthPruneKeysCommand.php +++ /dev/null @@ -1,77 +0,0 @@ -error("Key store path does not exist: {$storage}"); - return self::FAILURE; - } - - $days = (int) $this->option('days'); - $cutoff = now()->subDays($days); - - $candidates = collect(File::directories($storage)) - ->reject(fn ($path) => Str::lower(basename($path)) === 'archive') - ->filter(function (string $path) use ($currentKid, $cutoff) { - $kid = basename($path); - if ($kid === $currentKid) { - return false; - } - - $lastModified = File::lastModified($path); - - return $lastModified !== false && $cutoff->greaterThan(\Carbon\Carbon::createFromTimestamp($lastModified)); - }) - ->values(); - - if ($candidates->isEmpty()) { - $this->info("No legacy key directories older than {$days} days were found."); - return self::SUCCESS; - } - - $this->table( - ['KID', 'Last Modified', 'Path'], - $candidates->map(fn ($path) => [ - basename($path), - date('c', File::lastModified($path)), - $path, - ]) - ); - - if ($this->option('dry-run')) { - $this->info('Dry run complete. No keys were removed.'); - return self::SUCCESS; - } - - if (! $this->option('force') && ! $this->confirm('Remove the listed legacy key directories?', false)) { - $this->warn('Prune cancelled.'); - return self::SUCCESS; - } - - foreach ($candidates as $path) { - File::deleteDirectory($path); - } - - $this->info('Legacy key directories pruned.'); - - return self::SUCCESS; - } -} diff --git a/app/Console/Commands/OAuthRotateKeysCommand.php b/app/Console/Commands/OAuthRotateKeysCommand.php deleted file mode 100644 index 92ed3eb..0000000 --- a/app/Console/Commands/OAuthRotateKeysCommand.php +++ /dev/null @@ -1,113 +0,0 @@ -option('kid') ?: 'kid-'.now()->format('YmdHis'); - - if (! $this->option('force') && - ! $this->confirm("Rotate JWT keys? Current kid: {$currentKid}. New kid: {$newKid}", true) - ) { - $this->info('Rotation cancelled.'); - return self::SUCCESS; - } - - File::ensureDirectoryExists($storage); - - $archiveDir = $this->archiveExistingKeys($storage, $currentKid); - - $newDirectory = $storage.DIRECTORY_SEPARATOR.$newKid; - if (File::exists($newDirectory)) { - $this->error("Target directory already exists: {$newDirectory}"); - return self::FAILURE; - } - - File::makeDirectory($newDirectory, 0700, true); - $this->generateKeyPair($newDirectory); - - $this->info('New signing keys generated.'); - $this->line("Path: {$newDirectory}"); - - if ($archiveDir) { - $this->line("Previous keys archived at: {$archiveDir}"); - $this->line('Existing key remains available for token verification until you prune it.'); - } - - $this->warn("Update OAUTH_JWT_KID in your environment configuration to: {$newKid}"); - $this->info('Run `php artisan oauth:list-keys` to verify active signing directories.'); - $this->info('Once legacy tokens expire, run `php artisan oauth:prune-keys` to remove retired keys.'); - - return self::SUCCESS; - } - - private function archiveExistingKeys(string $storage, string $kid): ?string - { - $existingDir = $storage.DIRECTORY_SEPARATOR.$kid; - $legacyPublic = storage_path('app/public.key'); - $legacyPrivate = storage_path('app/private.key'); - - if (File::exists($existingDir)) { - $archiveDir = $storage.DIRECTORY_SEPARATOR.'archive'.DIRECTORY_SEPARATOR.$kid.'-'.now()->format('YmdHis'); - File::ensureDirectoryExists(dirname($archiveDir)); - File::copyDirectory($existingDir, $archiveDir); - return $archiveDir; - } - - if (File::exists($legacyPublic) || File::exists($legacyPrivate)) { - $archiveDir = $storage.DIRECTORY_SEPARATOR.'archive'.DIRECTORY_SEPARATOR.'legacy-'.now()->format('YmdHis'); - File::ensureDirectoryExists($archiveDir); - - if (File::exists($legacyPublic)) { - File::copy($legacyPublic, $archiveDir.DIRECTORY_SEPARATOR.'public.key'); - } - - if (File::exists($legacyPrivate)) { - File::copy($legacyPrivate, $archiveDir.DIRECTORY_SEPARATOR.'private.key'); - } - - return $archiveDir; - } - - return null; - } - - private function generateKeyPair(string $directory): void - { - $config = [ - 'digest_alg' => OPENSSL_ALGO_SHA256, - 'private_key_bits' => 4096, - 'private_key_type' => OPENSSL_KEYTYPE_RSA, - ]; - - $resource = openssl_pkey_new($config); - if (! $resource) { - throw new \RuntimeException('Failed to generate key pair'); - } - - openssl_pkey_export($resource, $privateKey); - $details = openssl_pkey_get_details($resource); - $publicKey = $details['key'] ?? null; - - if (! $publicKey) { - throw new \RuntimeException('Unable to extract public key'); - } - - File::put($directory.DIRECTORY_SEPARATOR.'private.key', $privateKey); - File::chmod($directory.DIRECTORY_SEPARATOR.'private.key', 0600); - - File::put($directory.DIRECTORY_SEPARATOR.'public.key', $publicKey); - File::chmod($directory.DIRECTORY_SEPARATOR.'public.key', 0644); - } -} diff --git a/app/Http/Controllers/Auth/AuthenticatedSessionController.php b/app/Http/Controllers/Auth/AuthenticatedSessionController.php index fc6cf5a..8676cd4 100644 --- a/app/Http/Controllers/Auth/AuthenticatedSessionController.php +++ b/app/Http/Controllers/Auth/AuthenticatedSessionController.php @@ -50,12 +50,24 @@ class AuthenticatedSessionController extends Controller return Inertia::location(route('verification.notice')); } + $intended = $this->resolveIntended($request); + if ($intended !== null) { + $this->rememberTenantAdminTarget($request, $intended); + + return Inertia::location($intended); + } + $returnTo = $this->resolveReturnTo($request); if ($returnTo !== null) { + $this->rememberTenantAdminTarget($request, $returnTo); + return Inertia::location($returnTo); } - return Inertia::location($this->defaultAdminPath()); + $default = $this->defaultAdminPath(); + $this->rememberTenantAdminTarget($request, $default); + + return Inertia::location($default); } /** @@ -79,7 +91,29 @@ class AuthenticatedSessionController extends Controller return null; } - return $this->decodeReturnTo($encoded, $request); + return $this->normalizeTenantAdminTarget( + $this->decodeReturnTo($encoded, $request), + $request + ); + } + + private function resolveIntended(Request $request): ?string + { + $intended = $request->session()->pull('url.intended'); + + if (! is_string($intended)) { + return null; + } + + $trimmed = trim($intended); + if ($trimmed === '') { + return null; + } + + return $this->normalizeTenantAdminTarget( + $this->decodeReturnTo($trimmed, $request), + $request + ); } private function decodeReturnTo(string $value, Request $request): ?string @@ -113,12 +147,73 @@ class AuthenticatedSessionController extends Controller private function defaultAdminPath(): string { - $base = rtrim(route('tenant.admin.app', absolute: false), '/'); - if ($base === '') { - $base = '/event-admin'; + $user = Auth::user(); + + // Block users with 'user' role - redirect to package selection + if ($user && $user->role === 'user') { + return '/packages'; } - return $base.'/events'; + // Super admins go to Filament superadmin panel + if ($user && $user->role === 'super_admin') { + return '/admin'; + } + + // Tenant admins go to their PWA dashboard + if ($user && $user->role === 'tenant_admin') { + return '/event-admin/dashboard'; + } + + // Fallback: redirect to packages (for users with no role) + return '/packages'; + } + + private function normalizeTenantAdminTarget(?string $target, Request $request): ?string + { + $user = Auth::user(); + + if (! $user || $user->role !== 'tenant_admin') { + return $target; + } + + if ($target === null || $target === '') { + return '/event-admin/dashboard'; + } + + $parsed = parse_url($target); + $path = $target; + $hasScheme = false; + + if ($parsed !== false) { + $hasScheme = isset($parsed['scheme']); + $host = $parsed['host'] ?? null; + $scheme = $parsed['scheme'] ?? null; + $requestHost = parse_url($request->getSchemeAndHttpHost(), PHP_URL_HOST); + + if ($scheme && $host && $requestHost && ! Str::endsWith($host, $requestHost)) { + return '/event-admin/dashboard'; + } + + if (isset($parsed['path'])) { + $path = $parsed['path']; + if (isset($parsed['query'])) { + $path .= '?'.$parsed['query']; + } + if (isset($parsed['fragment'])) { + $path .= '#'.$parsed['fragment']; + } + } + } + + if (! str_starts_with($path, '/')) { + $path = '/'.$path; + } + + if (str_starts_with($path, '/event-admin') || str_starts_with($path, '/api/v1/oauth/authorize')) { + return $hasScheme ? $target : $path; + } + + return '/event-admin/dashboard'; } private function decodeBase64Url(string $value): ?string @@ -137,4 +232,66 @@ class AuthenticatedSessionController extends Controller return $decoded; } + + private function rememberTenantAdminTarget(Request $request, ?string $target): void + { + $user = Auth::user(); + + if (! $user || $user->role !== 'tenant_admin') { + return; + } + + if (! is_string($target) || $target === '') { + return; + } + + $normalized = $this->normalizeTenantAdminTarget($target, $request); + + if (! is_string($normalized) || $normalized === '') { + return; + } + + $path = $this->extractTenantAdminPath($normalized); + + if ($path === null) { + return; + } + + $request->session()->put('tenant_admin.return_to', $path); + } + + private function extractTenantAdminPath(string $target): ?string + { + $value = trim($target); + + if ($value === '') { + return null; + } + + if (str_starts_with($value, '/event-admin')) { + return $value; + } + + $parsed = parse_url($value); + + if ($parsed === false) { + return null; + } + + $path = $parsed['path'] ?? ''; + + if ($path === '' || ! str_starts_with($path, '/event-admin')) { + return null; + } + + if (isset($parsed['query'])) { + $path .= '?'.$parsed['query']; + } + + if (isset($parsed['fragment'])) { + $path .= '#'.$parsed['fragment']; + } + + return $path; + } } diff --git a/app/Http/Controllers/DashboardController.php b/app/Http/Controllers/DashboardController.php index f18a59b..542c4a2 100644 --- a/app/Http/Controllers/DashboardController.php +++ b/app/Http/Controllers/DashboardController.php @@ -13,13 +13,26 @@ use Illuminate\Http\Request; use Illuminate\Support\Arr; use Illuminate\Support\Collection; use Inertia\Inertia; -use Inertia\Response; class DashboardController extends Controller { - public function __invoke(Request $request, DashboardSummaryService $summaryService): Response + public function __invoke(Request $request, DashboardSummaryService $summaryService) { $user = $request->user(); + + if ($user && $user->role === 'tenant_admin') { + $returnTarget = $this->consumeTenantAdminTarget($request); + + if ($returnTarget !== null) { + return redirect($returnTarget); + } + } + + // Block users with 'user' role from accessing dashboard + if ($user && $user->role === 'user') { + return redirect('/packages'); + } + $tenant = $user?->tenant; $summary = $tenant instanceof Tenant @@ -65,6 +78,21 @@ class DashboardController extends Controller ]); } + private function consumeTenantAdminTarget(Request $request): ?string + { + $target = $request->session()->pull('tenant_admin.return_to'); + + if (! is_string($target)) { + return null; + } + + if (! str_starts_with($target, '/event-admin')) { + return null; + } + + return $target; + } + private function collectUpcomingEvents(Tenant $tenant): Collection { return Event::query() diff --git a/app/Http/Controllers/OAuthController.php b/app/Http/Controllers/OAuthController.php deleted file mode 100644 index df72406..0000000 --- a/app/Http/Controllers/OAuthController.php +++ /dev/null @@ -1,884 +0,0 @@ -authorizeErrorResponse( - $request, - 'login_required', - 'Please sign in to continue.', - Response::HTTP_UNAUTHORIZED - ); - } - - $validator = Validator::make($request->all(), [ - 'client_id' => 'required|string', - 'redirect_uri' => 'required|url', - 'response_type' => 'required|in:code', - 'scope' => 'required|string', - 'state' => 'nullable|string', - 'code_challenge' => 'required|string', - 'code_challenge_method' => 'required|in:S256,plain', - ]); - - if ($validator->fails()) { - return $this->authorizeErrorResponse( - $request, - 'invalid_request', - 'The authorization request is invalid.', - Response::HTTP_BAD_REQUEST, - ['errors' => $validator->errors()->toArray()] - ); - } - - /** @var OAuthClient|null $client */ - $client = OAuthClient::query() - ->where('client_id', $request->string('client_id')) - ->where('is_active', true) - ->first(); - - if (! $client) { - return $this->authorizeErrorResponse( - $request, - 'invalid_client', - 'The specified client could not be found.', - Response::HTTP_UNAUTHORIZED - ); - } - - $allowedRedirects = (array) $client->redirect_uris; - if (! in_array($request->redirect_uri, $allowedRedirects, true)) { - return $this->authorizeErrorResponse( - $request, - 'invalid_redirect', - 'The redirect URI is not registered for this client.', - Response::HTTP_BAD_REQUEST - ); - } - - $requestedScopes = $this->parseScopes($request->string('scope')); - $availableScopes = (array) $client->scopes; - if (! $this->scopesAreAllowed($requestedScopes, $availableScopes)) { - return $this->authorizeErrorResponse( - $request, - 'invalid_scope', - 'The client requested scopes that are not permitted.', - Response::HTTP_BAD_REQUEST - ); - } - - /** @var User $user */ - $user = Auth::user(); - - $tenantId = $this->resolveTenantId($client, $user); - if (! $tenantId) { - return $this->authorizeErrorResponse( - $request, - 'tenant_mismatch', - 'You do not have access to the requested tenant.', - Response::HTTP_FORBIDDEN, - ['client_id' => $client->client_id] - ); - } - - $code = Str::random(64); - $codeId = (string) Str::uuid(); - $expiresAt = now()->addMinutes(self::AUTH_CODE_TTL_MINUTES); - $cacheKey = $this->cacheKeyForCode($code); - - Cache::put($cacheKey, [ - 'id' => $codeId, - 'client_id' => $client->client_id, - 'tenant_id' => $tenantId, - 'redirect_uri' => $request->redirect_uri, - 'scopes' => $requestedScopes, - 'state' => $request->state, - 'code_challenge' => $request->code_challenge, - 'code_challenge_method' => $request->code_challenge_method, - 'expires_at' => $expiresAt, - ], $expiresAt); - - OAuthCode::create([ - 'id' => $codeId, - 'client_id' => $client->client_id, - 'user_id' => (string) $user->getAuthIdentifier(), - 'code' => Hash::make($code), - 'code_challenge' => $request->code_challenge, - 'state' => $request->state, - 'redirect_uri' => $request->redirect_uri, - 'scope' => implode(' ', $requestedScopes), - 'expires_at' => $expiresAt, - ]); - - $redirectUrl = $request->redirect_uri.'?'.http_build_query([ - 'code' => $code, - 'state' => $request->state, - ]); - - if ($this->shouldReturnJsonAuthorizeResponse($request)) { - return response()->json([ - 'code' => $code, - 'state' => $request->state, - 'redirect_url' => $redirectUrl, - ]); - } - - return redirect()->away($redirectUrl); - } - - private function resolveTenantId(OAuthClient $client, User $user): ?int - { - if ($client->tenant_id !== null) { - if ((int) $client->tenant_id === (int) ($user->tenant_id ?? 0) || $user->role === 'super_admin') { - return (int) $client->tenant_id; - } - - return null; - } - - if ($user->tenant_id !== null) { - return (int) $user->tenant_id; - } - - return null; - } - - private function rememberIntendedUrl(Request $request): void - { - session()->put('url.intended', $request->fullUrl()); - } - - private function authorizeErrorResponse( - Request $request, - string $code, - string $message, - int $status, - array $meta = [] - ) { - $this->rememberIntendedUrl($request); - - if ($this->shouldReturnJsonAuthorizeResponse($request)) { - $payload = [ - 'error' => $code, - 'error_description' => $message, - ]; - - if ($meta !== []) { - $payload['meta'] = $meta; - } - - return response()->json($payload, $status); - } - - $query = [ - 'error' => $code, - 'error_description' => $message, - 'return_to' => $this->encodeReturnTo($request->fullUrl()), - ]; - - if ($meta !== []) { - $metaJson = json_encode($meta, JSON_UNESCAPED_SLASHES); - if ($metaJson !== false) { - $query['error_meta'] = $this->encodeReturnTo($metaJson); - } - } - - $redirectUrl = route('tenant.admin.login').'?'.http_build_query($query); - - return redirect()->to($redirectUrl); - } - - private function encodeReturnTo(?string $value): string - { - $value ??= ''; - - return rtrim(strtr(base64_encode($value), '+/', '-_'), '='); - } - - /** - * Token endpoint - Code exchange & refresh - */ - public function token(Request $request) - { - $grantType = (string) $request->string('grant_type'); - - if ($grantType === 'authorization_code') { - return $this->handleAuthorizationCodeGrant($request); - } - - if ($grantType === 'refresh_token') { - return $this->handleRefreshTokenGrant($request); - } - - return $this->errorResponse('Unsupported grant type', 400); - } - - /** - * Get tenant info based on decoded token - */ - public function me(Request $request) - { - $decoded = $request->attributes->get('decoded_token'); - $tenantId = Arr::get($decoded, 'tenant_id'); - - if (! $tenantId) { - return $this->errorResponse('Unauthenticated', 401); - } - - $tenant = Tenant::query()->find($tenantId); - if (! $tenant) { - Log::error('[OAuth] Tenant not found during token issuance', [ - 'client_id' => $request->client_id, - 'tenant_id' => $tenantId, - ]); - - return $this->errorResponse('Tenant not found', 404); - } - - return response()->json([ - 'id' => $tenant->id, - 'tenant_id' => $tenant->id, - 'name' => $tenant->name, - 'slug' => $tenant->slug, - 'email' => $tenant->contact_email, - 'active_reseller_package_id' => $tenant->active_reseller_package_id, - 'remaining_events' => $tenant->activeResellerPackage?->remaining_events ?? 0, - 'package_expires_at' => $tenant->activeResellerPackage?->expires_at, - 'features' => $tenant->features, - 'scopes' => Arr::get($decoded, 'scopes', []), - ]); - } - - private function handleAuthorizationCodeGrant(Request $request) - { - $validator = Validator::make($request->all(), [ - 'grant_type' => 'required|in:authorization_code', - 'code' => 'required|string', - 'client_id' => 'required|string', - 'redirect_uri' => 'required|url', - 'code_verifier' => 'required|string', - ]); - - if ($validator->fails()) { - return $this->errorResponse('Invalid request parameters', 400, $validator->errors()); - } - - $cacheKey = $this->cacheKeyForCode($request->code); - $cachedCode = Cache::get($cacheKey); - if (! $cachedCode || Arr::get($cachedCode, 'expires_at') < now()) { - Log::warning('[OAuth] Authorization code missing or expired', [ - 'client_id' => $request->client_id, - ]); - - return $this->errorResponse('Invalid or expired authorization code', 400); - } - - /** @var OAuthCode|null $oauthCode */ - $oauthCode = OAuthCode::query()->find($cachedCode['id']); - if (! $oauthCode || $oauthCode->isExpired() || ! Hash::check($request->code, $oauthCode->code)) { - Log::warning('[OAuth] Authorization code validation failed', [ - 'client_id' => $request->client_id, - 'oauth_code_id' => $oauthCode?->id, - ]); - - return $this->errorResponse('Invalid authorization code', 400); - } - - /** @var OAuthClient|null $client */ - $client = OAuthClient::query()->where('client_id', $request->client_id)->where('is_active', true)->first(); - if (! $client) { - return $this->errorResponse('Invalid client', 401); - } - - if ($request->redirect_uri !== Arr::get($cachedCode, 'redirect_uri')) { - return $this->errorResponse('Invalid redirect URI', 400); - } - - $codeChallengeMethod = Arr::get($cachedCode, 'code_challenge_method', 'S256'); - $expectedChallenge = $codeChallengeMethod === 'S256' - ? $this->base64urlEncode(hash('sha256', $request->code_verifier, true)) - : $request->code_verifier; - - if (! hash_equals($expectedChallenge, Arr::get($cachedCode, 'code_challenge'))) { - return $this->errorResponse('Invalid code verifier', 400); - } - - $tenantId = Arr::get($cachedCode, 'tenant_id') ?? $client->tenant_id; - $tenant = $tenantId ? Tenant::query()->find($tenantId) : null; - if (! $tenant) { - Log::error('[OAuth] Tenant not found during token issuance', [ - 'client_id' => $request->client_id, - 'oauth_code_id' => $oauthCode->id ?? null, - 'tenant_id' => $tenantId, - ]); - - return $this->errorResponse('Tenant not found', 404); - } - - $scopes = Arr::get($cachedCode, 'scopes', []); - if (empty($scopes)) { - $scopes = $this->parseScopes($oauthCode->scope); - } - - Cache::forget($cacheKey); - $oauthCode->delete(); - - $tokenResponse = $this->issueTokenPair($tenant, $client, $scopes, $request); - - return response()->json($tokenResponse); - } - - private function handleRefreshTokenGrant(Request $request) - { - $validator = Validator::make($request->all(), [ - 'grant_type' => 'required|in:refresh_token', - 'refresh_token' => 'required|string', - 'client_id' => 'required|string', - ]); - - if ($validator->fails()) { - return $this->errorResponse('Invalid request parameters', 400, $validator->errors()); - } - - $tokenParts = explode('|', $request->refresh_token, 2); - if (count($tokenParts) !== 2) { - return $this->errorResponse('Malformed refresh token', 400); - } - - [$refreshTokenId, $refreshTokenSecret] = $tokenParts; - - /** @var RefreshToken|null $storedRefreshToken */ - $storedRefreshToken = RefreshToken::query() - ->where('id', $refreshTokenId) - ->whereNull('revoked_at') - ->first(); - - if (! $storedRefreshToken) { - return $this->errorResponse('Invalid refresh token', 400); - } - - $storedRefreshToken->recordAudit('refresh_attempt', [ - 'client_id' => $request->client_id, - ], null, $request); - - if ($storedRefreshToken->client_id && $storedRefreshToken->client_id !== $request->client_id) { - $storedRefreshToken->recordAudit('client_mismatch', [ - 'expected_client' => $storedRefreshToken->client_id, - 'provided_client' => $request->client_id, - ], null, $request); - - return $this->errorResponse('Refresh token does not match client', 400); - } - - if ($storedRefreshToken->expires_at && $storedRefreshToken->expires_at->isPast()) { - $storedRefreshToken->revoke('expired', null, $request, [ - 'expired_at' => $storedRefreshToken->expires_at?->toIso8601String(), - ]); - - return $this->errorResponse('Refresh token expired', 400); - } - - if (! Hash::check($refreshTokenSecret, $storedRefreshToken->token)) { - $storedRefreshToken->recordAudit('invalid_secret', [], null, $request); - $storedRefreshToken->revoke('invalid_secret', null, $request, [ - 'client_id' => $request->client_id, - ]); - - return $this->errorResponse('Invalid refresh token', 400); - } - - $storedIp = (string) ($storedRefreshToken->ip_address ?? ''); - $currentIp = (string) ($request->ip() ?? ''); - - if (config('oauth.refresh_tokens.enforce_ip_binding', true) && ! $this->ipMatches($storedIp, $currentIp)) { - Log::warning('[OAuth] Refresh token rejected due to IP mismatch', [ - 'client_id' => $request->client_id, - 'refresh_token_id' => $storedRefreshToken->id, - 'stored_ip' => $storedIp, - 'current_ip' => $currentIp, - ]); - - $storedRefreshToken->revoke('ip_mismatch', null, $request, [ - 'stored_ip' => $storedIp, - 'current_ip' => $currentIp, - ]); - - return $this->errorResponse('Refresh token cannot be used from this IP address', 403); - } - - $client = OAuthClient::query()->where('client_id', $request->client_id)->where('is_active', true)->first(); - if (! $client) { - return $this->errorResponse('Invalid client', 401); - } - - $tenant = Tenant::query()->find($storedRefreshToken->tenant_id); - if (! $tenant) { - Log::error('[OAuth] Tenant not found during token issuance', [ - 'client_id' => $request->client_id, - 'refresh_token_id' => $storedRefreshToken->id, - 'tenant_id' => $storedRefreshToken->tenant_id, - ]); - - $storedRefreshToken->revoke('tenant_missing', null, $request, [ - 'missing_tenant_id' => $storedRefreshToken->tenant_id, - ]); - - return $this->errorResponse('Tenant not found', 404); - } - - $scopes = $this->parseScopes($storedRefreshToken->scope); - - $storedRefreshToken->forceFill([ - 'last_used_at' => now(), - ])->save(); - - $storedRefreshToken->recordAudit('refreshed', [ - 'client_id' => $request->client_id, - ], null, $request); - - $tokenResponse = $this->issueTokenPair($tenant, $client, $scopes, $request); - - $newComposite = $tokenResponse['refresh_token'] ?? null; - $newRefreshTokenId = null; - - if ($newComposite && str_contains($newComposite, '|')) { - [$newRefreshTokenId] = explode('|', $newComposite, 2); - } - - $storedRefreshToken->revoke('rotated', null, $request, [ - 'replaced_by' => $newRefreshTokenId, - ]); - - return response()->json($tokenResponse); - } - - private function issueTokenPair(Tenant $tenant, OAuthClient $client, array $scopes, Request $request): array - { - $scopes = array_values(array_unique($scopes)); - $expiresIn = self::ACCESS_TOKEN_TTL_SECONDS; - $issuedAt = now(); - $jti = (string) Str::uuid(); - $expiresAt = $issuedAt->copy()->addSeconds($expiresIn); - - $accessToken = $this->generateJWT( - $tenant->id, - $client->client_id, - $scopes, - 'access', - $expiresIn, - $jti, - $issuedAt->timestamp, - $expiresAt->timestamp - ); - - TenantToken::create([ - 'id' => (string) Str::uuid(), - 'tenant_id' => $tenant->id, - 'jti' => $jti, - 'token_type' => 'access', - 'expires_at' => $expiresAt, - ]); - - $refreshToken = $this->createRefreshToken($tenant, $client, $scopes, $jti, $request); - - return [ - 'token_type' => 'Bearer', - 'access_token' => $accessToken, - 'refresh_token' => $refreshToken, - 'expires_in' => $expiresIn, - 'scope' => implode(' ', $scopes), - ]; - } - - private function shouldReturnJsonAuthorizeResponse(Request $request): bool - { - if ($request->expectsJson() || $request->ajax()) { - return true; - } - - $redirectUri = (string) $request->string('redirect_uri'); - $redirectHost = $redirectUri !== '' ? parse_url($redirectUri, PHP_URL_HOST) : null; - $requestHost = $request->getHost(); - - if ($redirectHost && ! $this->hostsMatch($requestHost, $redirectHost)) { - return true; - } - - $origin = $request->headers->get('Origin'); - if ($origin) { - $originHost = parse_url($origin, PHP_URL_HOST); - if ($originHost && $redirectHost && ! $this->hostsMatch($originHost, $redirectHost)) { - return true; - } - } - - return false; - } - - private function hostsMatch(?string $first, ?string $second): bool - { - if (! $first || ! $second) { - return false; - } - - return strtolower($first) === strtolower($second); - } - - private function createRefreshToken(Tenant $tenant, OAuthClient $client, array $scopes, string $accessTokenJti, Request $request): string - { - $refreshTokenId = (string) Str::uuid(); - $secret = Str::random(64); - $composite = $refreshTokenId.'|'.$secret; - $expiresAt = now()->addDays(self::REFRESH_TOKEN_TTL_DAYS); - - /** @var RefreshToken $refreshToken */ - $refreshToken = RefreshToken::create([ - 'id' => $refreshTokenId, - 'tenant_id' => $tenant->id, - 'client_id' => $client->client_id, - 'token' => Hash::make($secret), - 'access_token' => $accessTokenJti, - 'expires_at' => $expiresAt, - 'last_used_at' => now(), - 'scope' => implode(' ', $scopes), - 'ip_address' => $request->ip(), - 'user_agent' => $request->userAgent(), - ]); - - $refreshToken->recordAudit('issued', [ - 'scopes' => $scopes, - ], null, $request); - - $maxActive = (int) config('oauth.refresh_tokens.max_active_per_tenant', 5); - - if ($maxActive > 0) { - $activeTokens = RefreshToken::query() - ->forTenant((string) $tenant->id) - ->active() - ->orderByDesc('created_at') - ->get(); - - if ($activeTokens->count() > $maxActive) { - $activeTokens - ->slice($maxActive) - ->each(function (RefreshToken $token) use ($request, $maxActive, $refreshToken): void { - $token->revoke('max_active_limit', null, $request, [ - 'threshold' => $maxActive, - 'new_token' => $refreshToken->id, - ]); - }); - } - } - - return $composite; - } - - private function generateJWT( - int $tenantId, - string $clientId, - array $scopes, - string $type, - int $expiresIn, - string $jti, - int $issuedAt, - int $expiresAt - ): string { - [$kid, , $privateKey] = $this->getSigningKeyPair(); - - $payload = [ - 'iss' => url('/'), - 'aud' => $clientId, - 'iat' => $issuedAt, - 'nbf' => $issuedAt, - 'exp' => $expiresAt, - 'sub' => $tenantId, - 'tenant_id' => $tenantId, - 'scopes' => $scopes, - 'type' => $type, - 'client_id' => $clientId, - 'jti' => $jti, - ]; - - return JWT::encode($payload, $privateKey, 'RS256', $kid, ['kid' => $kid]); - } - - private function getSigningKeyPair(): array - { - $kid = $this->currentKid(); - [$publicKey, $privateKey] = $this->ensureKeysForKid($kid); - - return [$kid, $publicKey, $privateKey]; - } - - private function currentKid(): string - { - return config('oauth.keys.current_kid', self::LEGACY_TOKEN_HEADER_KID); - } - - private function ensureKeysForKid(string $kid): array - { - $paths = $this->keyPaths($kid); - - if (! File::exists($paths['directory'])) { - File::makeDirectory($paths['directory'], 0700, true); - } - - $this->maybeMigrateLegacyKeys($paths); - - if (! File::exists($paths['public']) || ! File::exists($paths['private'])) { - $this->generateKeyPair($paths['directory']); - } - - return [ - File::get($paths['public']), - File::get($paths['private']), - ]; - } - - private function keyPaths(string $kid): array - { - $base = rtrim(config('oauth.keys.storage_path', storage_path('app/oauth-keys')), DIRECTORY_SEPARATOR); - $directory = $base.DIRECTORY_SEPARATOR.$kid; - - return [ - 'directory' => $directory, - 'public' => $directory.DIRECTORY_SEPARATOR.'public.key', - 'private' => $directory.DIRECTORY_SEPARATOR.'private.key', - ]; - } - - private function maybeMigrateLegacyKeys(array $paths): void - { - $legacyPublic = storage_path('app/public.key'); - $legacyPrivate = storage_path('app/private.key'); - - if (! File::exists($paths['public']) && File::exists($legacyPublic)) { - File::copy($legacyPublic, $paths['public']); - } - - if (! File::exists($paths['private']) && File::exists($legacyPrivate)) { - File::copy($legacyPrivate, $paths['private']); - } - } - - private function generateKeyPair(string $directory): void - { - $config = [ - 'digest_alg' => OPENSSL_ALGO_SHA256, - 'private_key_bits' => 4096, - 'private_key_type' => OPENSSL_KEYTYPE_RSA, - ]; - - $resource = openssl_pkey_new($config); - if (! $resource) { - throw new \RuntimeException('Failed to generate key pair'); - } - - openssl_pkey_export($resource, $privateKey); - $details = openssl_pkey_get_details($resource); - $publicKey = $details['key'] ?? null; - - if (! $publicKey) { - throw new \RuntimeException('Failed to extract public key'); - } - - File::put($directory.DIRECTORY_SEPARATOR.'private.key', $privateKey, true); - File::chmod($directory.DIRECTORY_SEPARATOR.'private.key', 0600); - - File::put($directory.DIRECTORY_SEPARATOR.'public.key', $publicKey, true); - File::chmod($directory.DIRECTORY_SEPARATOR.'public.key', 0644); - } - - private function scopesAreAllowed(array $requestedScopes, array $availableScopes): bool - { - if (empty($requestedScopes)) { - return false; - } - - $available = array_flip($availableScopes); - foreach ($requestedScopes as $scope) { - if (! isset($available[$scope])) { - return false; - } - } - - return true; - } - - private function parseScopes(?string $scopeString): array - { - if (! $scopeString) { - return []; - } - - return array_values(array_filter(explode(' ', trim($scopeString)))); - } - - private function errorResponse(string $message, int $status = 400, $errors = null) - { - $response = ['error' => $message]; - if ($errors) { - $response['errors'] = $errors; - } - - return response()->json($response, $status); - } - - private function ipMatches(string $storedIp, string $currentIp): bool - { - if ($storedIp === '' || $currentIp === '') { - return true; - } - - if (hash_equals($storedIp, $currentIp)) { - return true; - } - - if (! config('oauth.refresh_tokens.allow_subnet_match', false)) { - return false; - } - - if (filter_var($storedIp, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4) && filter_var($currentIp, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) { - $storedParts = explode('.', $storedIp); - $currentParts = explode('.', $currentIp); - - return $storedParts[0] === $currentParts[0] - && $storedParts[1] === $currentParts[1] - && $storedParts[2] === $currentParts[2]; - } - - return false; - } - - private function base64urlEncode(string $data): string - { - return rtrim(strtr(base64_encode($data), '+/', '-_'), '='); - } - - private function cacheKeyForCode(string $code): string - { - return 'oauth:code:'.hash('sha256', $code); - } - - /** - * Stripe Connect OAuth - Start connection - */ - public function stripeConnect(Request $request) - { - $tenant = $request->user()->tenant ?? null; - if (! $tenant) { - return ApiError::response( - 'tenant_not_found', - 'Tenant not found', - 'The authenticated user is not assigned to a tenant.', - Response::HTTP_NOT_FOUND - ); - } - - $state = Str::random(40); - session(['stripe_state' => $state, 'tenant_id' => $tenant->id]); - Cache::put("stripe_connect_state:{$tenant->id}", $state, now()->addMinutes(10)); - - $clientId = config('services.stripe.connect_client_id'); - $redirectUri = url('/api/v1/oauth/stripe-callback'); - $scopes = 'read_write_payments transfers'; - - $authUrl = "https://connect.stripe.com/oauth/authorize?response_type=code&client_id={$clientId}&scope={$scopes}&state={$state}&redirect_uri=".urlencode($redirectUri); - - return redirect($authUrl); - } - - /** - * Stripe Connect Callback - */ - public function stripeCallback(Request $request) - { - $code = $request->get('code'); - $state = $request->get('state'); - $error = $request->get('error'); - - if ($error) { - return redirect('/event-admin')->with('error', 'Stripe connection failed: '.$error); - } - - if (! $code || ! $state) { - return redirect('/event-admin')->with('error', 'Invalid callback parameters'); - } - - $sessionState = session('stripe_state'); - if (! hash_equals($state, (string) $sessionState)) { - return redirect('/event-admin')->with('error', 'Invalid state parameter'); - } - - $client = new Client; - $clientId = config('services.stripe.connect_client_id'); - $secret = config('services.stripe.connect_secret'); - $redirectUri = url('/api/v1/oauth/stripe-callback'); - - try { - $response = $client->post('https://connect.stripe.com/oauth/token', [ - 'form_params' => [ - 'grant_type' => 'authorization_code', - 'client_id' => $clientId, - 'client_secret' => $secret, - 'code' => $code, - 'redirect_uri' => $redirectUri, - ], - ]); - - $tokenData = json_decode($response->getBody()->getContents(), true); - - if (! isset($tokenData['stripe_user_id'])) { - return redirect('/event-admin')->with('error', 'Failed to connect Stripe account'); - } - - $tenant = Tenant::find(session('tenant_id')); - if ($tenant) { - $tenant->update(['stripe_account_id' => $tokenData['stripe_user_id']]); - } - - session()->forget(['stripe_state', 'tenant_id']); - - return redirect('/event-admin')->with('success', 'Stripe account connected successfully'); - } catch (\Exception $e) { - Log::error('Stripe OAuth error: '.$e->getMessage()); - - return redirect('/event-admin')->with('error', 'Connection error: '.$e->getMessage()); - } - } -} diff --git a/app/Http/Controllers/TenantAdminAuthController.php b/app/Http/Controllers/TenantAdminAuthController.php new file mode 100644 index 0000000..9ea3366 --- /dev/null +++ b/app/Http/Controllers/TenantAdminAuthController.php @@ -0,0 +1,32 @@ +role, ['tenant_admin', 'super_admin'])) { + return view('admin'); + } + + // Redirect users with 'user' role to packages + if ($user && $user->role === 'user') { + return redirect('/packages'); + } + + // Redirect unauthenticated users to the dedicated admin start flow + if (! $user) { + return redirect('/event-admin/start'); + } + + // Default: redirect to regular dashboard + return redirect('/dashboard'); + } +} diff --git a/app/Http/Controllers/TenantAdminGoogleController.php b/app/Http/Controllers/TenantAdminGoogleController.php index 36f1165..28e278f 100644 --- a/app/Http/Controllers/TenantAdminGoogleController.php +++ b/app/Http/Controllers/TenantAdminGoogleController.php @@ -66,7 +66,7 @@ class TenantAdminGoogleController extends Controller } } - return redirect()->intended(route('tenant.admin.app')); + return redirect()->intended('/event-admin/dashboard'); } private function sendBackWithError(Request $request, string $code, string $message): RedirectResponse diff --git a/app/Http/Middleware/RedirectIfAuthenticated.php b/app/Http/Middleware/RedirectIfAuthenticated.php new file mode 100644 index 0000000..2b81e9f --- /dev/null +++ b/app/Http/Middleware/RedirectIfAuthenticated.php @@ -0,0 +1,147 @@ +check()) { + continue; + } + + $user = Auth::guard($guard)->user(); + + if ($this->shouldBypassForTenantAdmin($request, $user)) { + continue; + } + + if ($user && $user->role === 'tenant_admin') { + $this->storeTenantAdminTarget($request); + } + + return redirect($this->redirectPath($user)); + } + + return $next($request); + } + + private function shouldBypassForTenantAdmin(Request $request, ?User $user): bool + { + if (! $user || $user->role !== 'tenant_admin') { + return false; + } + + $encoded = $request->string('return_to')->trim()->toString(); + + if ($encoded === '') { + return false; + } + + $decoded = $this->decodeReturnTo($encoded); + + if ($decoded === null) { + return false; + } + + $path = $this->normalizePath($decoded); + + return str_starts_with($path, '/event-admin'); + } + + private function decodeReturnTo(string $value): ?string + { + $padded = str_pad($value, strlen($value) + ((4 - (strlen($value) % 4)) % 4), '='); + $normalized = strtr($padded, '-_', '+/'); + $decoded = base64_decode($normalized, true); + + return $decoded === false ? null : $decoded; + } + + private function normalizePath(string $target): string + { + $trimmed = trim($target); + + if ($trimmed === '') { + return ''; + } + + if (str_starts_with($trimmed, '/')) { + return $trimmed; + } + + $parsed = parse_url($trimmed); + + if ($parsed === false || ! isset($parsed['path'])) { + return ''; + } + + $path = $parsed['path']; + + if (! str_starts_with($path, '/')) { + $path = '/'.$path; + } + + if (isset($parsed['query'])) { + $path .= '?'.$parsed['query']; + } + + if (isset($parsed['fragment'])) { + $path .= '#'.$parsed['fragment']; + } + + return $path; + } + + private function redirectPath(?User $user): string + { + if ($user && $user->role === 'tenant_admin') { + return '/event-admin/dashboard'; + } + + if ($user && $user->role === 'super_admin') { + return '/admin'; + } + + if ($user && $user->role === 'user') { + return '/packages'; + } + + return '/dashboard'; + } + + private function storeTenantAdminTarget(Request $request): void + { + $encoded = $request->string('return_to')->trim()->toString(); + + if ($encoded === '') { + return; + } + + $decoded = $this->decodeReturnTo($encoded); + + if ($decoded === null) { + return; + } + + $path = $this->normalizePath($decoded); + + if (! str_starts_with($path, '/event-admin')) { + return; + } + + $request->session()->put('tenant_admin.return_to', $path); + } +} diff --git a/app/Http/Middleware/TenantIsolation.php b/app/Http/Middleware/TenantIsolation.php index a76f135..3844010 100644 --- a/app/Http/Middleware/TenantIsolation.php +++ b/app/Http/Middleware/TenantIsolation.php @@ -18,6 +18,12 @@ class TenantIsolation { $tenantId = $request->attributes->get('tenant_id'); + $abilities = $request->user()?->currentAccessToken()?->abilities ?? []; + + if (! $tenantId && in_array('super-admin', $abilities, true)) { + return $next($request); + } + if (! $tenantId) { return $this->missingTenantIdResponse(); } diff --git a/app/Http/Middleware/TenantTokenGuard.php b/app/Http/Middleware/TenantTokenGuard.php deleted file mode 100644 index c555c75..0000000 --- a/app/Http/Middleware/TenantTokenGuard.php +++ /dev/null @@ -1,309 +0,0 @@ -getTokenFromRequest($request); - - if (! $token) { - return $this->errorResponse( - 'token_missing', - 'Token Missing', - 'Authentication token not provided.', - Response::HTTP_UNAUTHORIZED - ); - } - - try { - $decoded = $this->decodeToken($token); - } catch (\Exception $e) { - return $this->errorResponse( - 'token_invalid', - 'Invalid Token', - 'Authentication token cannot be decoded.', - Response::HTTP_UNAUTHORIZED - ); - } - - if ($this->isTokenBlacklisted($decoded)) { - return $this->errorResponse( - 'token_revoked', - 'Token Revoked', - 'The provided token is no longer valid.', - Response::HTTP_UNAUTHORIZED, - ['jti' => $decoded['jti'] ?? null] - ); - } - - if (! empty($scopes) && ! $this->hasScopes($decoded, $scopes)) { - return $this->errorResponse( - 'token_scope_violation', - 'Insufficient Scopes', - 'The provided token does not include the required scopes.', - Response::HTTP_FORBIDDEN, - ['required_scopes' => $scopes, 'token_scopes' => $decoded['scopes'] ?? []] - ); - } - - if (($decoded['exp'] ?? 0) < time()) { - $this->blacklistToken($decoded); - - return $this->errorResponse( - 'token_expired', - 'Token Expired', - 'Authentication token has expired.', - Response::HTTP_UNAUTHORIZED, - ['expired_at' => $decoded['exp'] ?? null] - ); - } - - $tenantId = $decoded['tenant_id'] ?? $decoded['sub'] ?? null; - if (! $tenantId) { - return $this->errorResponse( - 'token_payload_invalid', - 'Invalid Token Payload', - 'Authentication token does not include tenant context.', - Response::HTTP_UNAUTHORIZED - ); - } - - $tenant = Tenant::query()->find($tenantId); - if (! $tenant) { - return $this->errorResponse( - 'tenant_not_found', - 'Tenant Not Found', - 'The tenant belonging to the token could not be located.', - Response::HTTP_NOT_FOUND, - ['tenant_id' => $tenantId] - ); - } - - $scopesFromToken = $this->normaliseScopes($decoded['scopes'] ?? []); - - $principal = new GenericUser([ - 'id' => $tenant->id, - 'tenant_id' => $tenant->id, - 'tenant' => $tenant, - 'scopes' => $scopesFromToken, - 'client_id' => $decoded['client_id'] ?? null, - 'jti' => $decoded['jti'] ?? null, - 'token_type' => $decoded['type'] ?? 'access', - ]); - - Auth::setUser($principal); - $request->setUserResolver(fn () => $principal); - - $request->merge([ - 'tenant_id' => $tenant->id, - 'tenant' => $tenant, - ]); - $request->attributes->set('tenant_id', $tenant->id); - $request->attributes->set('tenant', $tenant); - $request->attributes->set('decoded_token', $decoded); - - return $next($request); - } - - /** - * Get token from request (Bearer or header) - */ - private function getTokenFromRequest(Request $request): ?string - { - $header = $request->header('Authorization'); - - if (is_string($header) && str_starts_with($header, 'Bearer ')) { - return substr($header, 7); - } - - if ($request->header('X-API-Token')) { - return $request->header('X-API-Token'); - } - - return null; - } - - /** - * Decode JWT token - */ - private function decodeToken(string $token): array - { - $kid = $this->extractKid($token); - $publicKey = $this->loadPublicKeyForKid($kid); - - if (! $publicKey) { - throw new \Exception('JWT public key not found'); - } - - $decoded = JWT::decode($token, new Key($publicKey, 'RS256')); - - return (array) $decoded; - } - - private function extractKid(string $token): ?string - { - $segments = explode('.', $token); - if (count($segments) < 2) { - return null; - } - - $decodedHeader = json_decode(base64_decode($segments[0]), true); - - return is_array($decodedHeader) ? ($decodedHeader['kid'] ?? null) : null; - } - - private function loadPublicKeyForKid(?string $kid): ?string - { - $resolvedKid = $kid ?? config('oauth.keys.current_kid', self::LEGACY_KID); - $base = rtrim(config('oauth.keys.storage_path', storage_path('app/oauth-keys')), DIRECTORY_SEPARATOR); - $path = $base.DIRECTORY_SEPARATOR.$resolvedKid.DIRECTORY_SEPARATOR.'public.key'; - - if (File::exists($path)) { - return File::get($path); - } - - $legacyPath = storage_path('app/public.key'); - if (File::exists($legacyPath)) { - return File::get($legacyPath); - } - - return null; - } - - /** - * Check if token is blacklisted - */ - private function isTokenBlacklisted(array $decoded): bool - { - $jti = $decoded['jti'] ?? null; - if (! $jti) { - return false; - } - - $cacheKey = "blacklisted_token:{$jti}"; - if (Cache::has($cacheKey)) { - return true; - } - - $tokenRecord = TenantToken::query()->where('jti', $jti)->first(); - if (! $tokenRecord) { - return false; - } - - if ($tokenRecord->revoked_at) { - Cache::put($cacheKey, true, $this->cacheTtlFromDecoded($decoded)); - - return true; - } - - if ($tokenRecord->expires_at && $tokenRecord->expires_at->isPast()) { - $tokenRecord->update(['revoked_at' => now()]); - Cache::put($cacheKey, true, $this->cacheTtlFromDecoded($decoded)); - - return true; - } - - return false; - } - - /** - * Add token to blacklist - */ - private function blacklistToken(array $decoded): void - { - $jti = $decoded['jti'] ?? md5(($decoded['sub'] ?? '').($decoded['iat'] ?? '')); - $cacheKey = "blacklisted_token:{$jti}"; - - Cache::put($cacheKey, true, $this->cacheTtlFromDecoded($decoded)); - - $tenantId = $decoded['tenant_id'] ?? $decoded['sub'] ?? null; - $tokenType = $decoded['type'] ?? 'access'; - - $record = TenantToken::query()->where('jti', $jti)->first(); - if ($record) { - $record->update([ - 'revoked_at' => now(), - 'expires_at' => $record->expires_at ?? now(), - ]); - - return; - } - - if ($tenantId === null) { - return; - } - - TenantToken::create([ - 'id' => (string) Str::uuid(), - 'tenant_id' => $tenantId, - 'jti' => $jti, - 'token_type' => $tokenType, - 'expires_at' => now(), - 'revoked_at' => now(), - ]); - } - - /** - * Check if token has required scopes - */ - private function hasScopes(array $decoded, array $requiredScopes): bool - { - $tokenScopes = $this->normaliseScopes($decoded['scopes'] ?? []); - - foreach ($requiredScopes as $scope) { - if (! in_array($scope, $tokenScopes, true)) { - return false; - } - } - - return true; - } - - private function normaliseScopes(mixed $scopes): array - { - if (is_array($scopes)) { - return array_values(array_filter($scopes, fn ($scope) => $scope !== null && $scope !== '')); - } - - if (is_string($scopes)) { - return array_values(array_filter(explode(' ', $scopes))); - } - - return []; - } - - private function cacheTtlFromDecoded(array $decoded): int - { - $exp = $decoded['exp'] ?? time(); - $ttl = max($exp - time(), 60); - - return $ttl; - } - - private function errorResponse(string $code, string $title, string $message, int $status, array $meta = []): JsonResponse - { - return ApiError::response($code, $title, $message, $status, $meta); - } -} diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 4e434ca..4fe4169 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -132,6 +132,10 @@ class AppServiceProvider extends ServiceProvider 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')); + }); + Inertia::share('locale', fn () => app()->getLocale()); Inertia::share('analytics', static function () { $config = config('services.matomo'); diff --git a/bootstrap/app.php b/bootstrap/app.php index b28f081..6016cdb 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -1,6 +1,7 @@ withCommands([ - \App\Console\Commands\OAuthRotateKeysCommand::class, - \App\Console\Commands\OAuthListKeysCommand::class, - \App\Console\Commands\OAuthPruneKeysCommand::class, \App\Console\Commands\CheckEventPackages::class, ]) ->withSchedule(function (\Illuminate\Console\Scheduling\Schedule $schedule) { @@ -29,12 +27,12 @@ return Application::configure(basePath: dirname(__DIR__)) }) ->withMiddleware(function (Middleware $middleware) { $middleware->alias([ - 'tenant.token' => TenantTokenGuard::class, 'tenant.isolation' => TenantIsolation::class, 'package.check' => \App\Http\Middleware\PackageMiddleware::class, 'locale' => \App\Http\Middleware\SetLocale::class, 'superadmin.auth' => \App\Http\Middleware\SuperAdminAuth::class, 'credit.check' => CreditCheckMiddleware::class, + 'tenant.admin' => EnsureTenantAdminToken::class, ]); $middleware->encryptCookies(except: ['appearance', 'sidebar_state']); diff --git a/database/factories/UserFactory.php b/database/factories/UserFactory.php index f95158b..78ed6c8 100644 --- a/database/factories/UserFactory.php +++ b/database/factories/UserFactory.php @@ -34,6 +34,7 @@ class UserFactory extends Factory 'email' => $this->faker->unique()->safeEmail(), 'address' => $this->faker->streetAddress(), 'phone' => $this->faker->phoneNumber(), + 'role' => 'user', // Regular users have 'user' role 'email_verified_at' => now(), 'password' => static::$password ??= Hash::make('password'), 'remember_token' => Str::random(10), diff --git a/database/migrations/2025_09_15_000000_create_oauth_system.php b/database/migrations/2025_09_15_000000_create_oauth_system.php deleted file mode 100644 index 0d632ac..0000000 --- a/database/migrations/2025_09_15_000000_create_oauth_system.php +++ /dev/null @@ -1,127 +0,0 @@ -string('id', 255)->primary(); - $table->string('client_id', 255)->unique(); - $table->string('client_secret', 255)->nullable(); - $table->text('redirect_uris')->nullable(); - $table->text('scopes')->default('tenant:read tenant:write'); - $table->boolean('is_active')->default(true); // From add_is_active - $table->foreignId('tenant_id')->nullable()->after('client_secret')->constrained('tenants')->nullOnDelete(); // From add_tenant_id - $table->timestamp('created_at')->useCurrent(); - $table->timestamp('updated_at')->useCurrent()->useCurrentOnUpdate(); - $table->index('tenant_id'); - }); - } else { - if (!Schema::hasColumn('oauth_clients', 'is_active')) { - Schema::table('oauth_clients', function (Blueprint $table) { - $table->boolean('is_active')->default(true)->after('scopes'); - }); - } - if (!Schema::hasColumn('oauth_clients', 'tenant_id')) { - Schema::table('oauth_clients', function (Blueprint $table) { - $table->foreignId('tenant_id')->nullable()->after('client_secret')->constrained('tenants')->nullOnDelete(); - $table->index('tenant_id'); - }); - } - } - - // Refresh Tokens - if (!Schema::hasTable('refresh_tokens')) { - Schema::create('refresh_tokens', function (Blueprint $table) { - $table->string('id', 255)->primary(); - $table->string('tenant_id', 255)->index(); - $table->string('client_id', 255)->nullable()->index(); // From add_client_id - $table->string('token', 255)->unique()->index(); - $table->string('access_token', 255)->nullable(); - $table->timestamp('expires_at')->nullable(); - $table->text('scope')->nullable(); - $table->string('ip_address', 45)->nullable(); - $table->text('user_agent')->nullable(); - $table->timestamp('created_at')->useCurrent(); - $table->timestamp('revoked_at')->nullable(); - $table->index('expires_at'); - }); - } else { - if (!Schema::hasColumn('refresh_tokens', 'client_id')) { - Schema::table('refresh_tokens', function (Blueprint $table) { - $table->string('client_id', 255)->nullable()->after('tenant_id')->index(); - }); - } - } - - // Tenant Tokens - if (!Schema::hasTable('tenant_tokens')) { - Schema::create('tenant_tokens', function (Blueprint $table) { - $table->string('id', 255)->primary(); - $table->string('tenant_id', 255)->index(); - $table->string('jti', 255)->unique()->index(); - $table->string('token_type', 50)->index(); - $table->timestamp('expires_at'); - $table->timestamp('revoked_at')->nullable(); - $table->timestamp('created_at')->useCurrent(); - $table->index('expires_at'); - }); - } - - // OAuth Codes - if (!Schema::hasTable('oauth_codes')) { - Schema::create('oauth_codes', function (Blueprint $table) { - $table->string('id', 255)->primary(); - $table->string('client_id', 255); - $table->string('user_id', 255); - $table->string('code', 255)->unique()->index(); - $table->string('code_challenge', 255); - $table->string('state', 255)->nullable(); - $table->string('redirect_uri', 255)->nullable(); - $table->text('scope')->nullable(); - $table->timestamp('expires_at'); - $table->timestamp('created_at')->useCurrent(); - $table->index('expires_at'); - $table->foreign('client_id')->references('client_id')->on('oauth_clients')->onDelete('cascade'); - }); - } - } - - public function down(): void - { - if (app()->environment('local', 'testing')) { - if (Schema::hasTable('oauth_codes')) { - Schema::table('oauth_codes', function (Blueprint $table) { - $table->dropForeign(['client_id']); - }); - Schema::dropIfExists('oauth_codes'); - } - if (Schema::hasColumn('refresh_tokens', 'client_id')) { - Schema::table('refresh_tokens', function (Blueprint $table) { - $table->dropIndex(['client_id']); - $table->dropColumn('client_id'); - }); - } - Schema::dropIfExists('refresh_tokens'); - Schema::dropIfExists('tenant_tokens'); - if (Schema::hasColumn('oauth_clients', 'tenant_id')) { - Schema::table('oauth_clients', function (Blueprint $table) { - $table->dropForeign(['tenant_id']); - $table->dropColumn('tenant_id'); - }); - } - if (Schema::hasColumn('oauth_clients', 'is_active')) { - Schema::table('oauth_clients', function (Blueprint $table) { - $table->dropColumn('is_active'); - }); - } - Schema::dropIfExists('oauth_clients'); - } - } -}; \ No newline at end of file diff --git a/resources/js/admin/api.ts b/resources/js/admin/api.ts index 3e74460..a570c4f 100644 --- a/resources/js/admin/api.ts +++ b/resources/js/admin/api.ts @@ -838,10 +838,17 @@ function eventEndpoint(slug: string): string { return `/api/v1/tenant/events/${encodeURIComponent(slug)}`; } -export async function getEvents(): Promise { - const response = await authorizedFetch('/api/v1/tenant/events'); - const data = await jsonOrThrow(response, 'Failed to load events'); - return (data.data ?? []).map(normalizeEvent); +export async function getEvents(options?: { force?: boolean }): Promise { + return cachedFetch( + CacheKeys.events, + async () => { + const response = await authorizedFetch('/api/v1/tenant/events'); + const data = await jsonOrThrow(response, 'Failed to load events'); + return (data.data ?? []).map(normalizeEvent); + }, + DEFAULT_CACHE_TTL, + options?.force === true, + ); } export async function createEvent(payload: EventSavePayload): Promise<{ event: TenantEvent; balance: number }> { @@ -851,7 +858,9 @@ export async function createEvent(payload: EventSavePayload): Promise<{ event: T body: JSON.stringify(payload), }); const data = await jsonOrThrow(response, 'Failed to create event'); - return { event: normalizeEvent(data.data), balance: data.balance }; + const result = { event: normalizeEvent(data.data), balance: data.balance }; + invalidateTenantApiCache([CacheKeys.events, CacheKeys.dashboard]); + return result; } export async function updateEvent(slug: string, payload: Partial): Promise { @@ -861,7 +870,9 @@ export async function updateEvent(slug: string, payload: Partial(response, 'Failed to update event'); - return normalizeEvent(data.data); + const event = normalizeEvent(data.data); + invalidateTenantApiCache([CacheKeys.events, CacheKeys.dashboard]); + return event; } export async function getEvent(slug: string): Promise { @@ -1130,38 +1141,52 @@ async function fetchTenantPackagesEndpoint(): Promise { return first; } -export async function getDashboardSummary(): Promise { - const response = await authorizedFetch('/api/v1/tenant/dashboard'); - if (response.status === 404) { - return null; - } - if (!response.ok) { - const payload = await safeJson(response); - const fallbackMessage = i18n.t('dashboard:errors.loadFailed', 'Dashboard konnte nicht geladen werden.'); - emitApiErrorEvent({ message: fallbackMessage, status: response.status }); - console.error('[API] Failed to load dashboard', response.status, payload); - throw new Error(fallbackMessage); - } - const json = (await response.json()) as JsonValue; - return normalizeDashboard(json); +export async function getDashboardSummary(options?: { force?: boolean }): Promise { + return cachedFetch( + CacheKeys.dashboard, + async () => { + const response = await authorizedFetch('/api/v1/tenant/dashboard'); + if (response.status === 404) { + return null; + } + if (!response.ok) { + const payload = await safeJson(response); + const fallbackMessage = i18n.t('dashboard:errors.loadFailed', 'Dashboard konnte nicht geladen werden.'); + emitApiErrorEvent({ message: fallbackMessage, status: response.status }); + console.error('[API] Failed to load dashboard', response.status, payload); + throw new Error(fallbackMessage); + } + const json = (await response.json()) as JsonValue; + return normalizeDashboard(json); + }, + DEFAULT_CACHE_TTL, + options?.force === true, + ); } -export async function getTenantPackagesOverview(): Promise<{ +export async function getTenantPackagesOverview(options?: { force?: boolean }): Promise<{ packages: TenantPackageSummary[]; activePackage: TenantPackageSummary | null; }> { - const response = await fetchTenantPackagesEndpoint(); - if (!response.ok) { - const payload = await safeJson(response); - const fallbackMessage = i18n.t('common:errors.generic', 'Etwas ist schiefgelaufen. Bitte versuche es erneut.'); - emitApiErrorEvent({ message: fallbackMessage, status: response.status }); - console.error('[API] Failed to load tenant packages', response.status, payload); - throw new Error(fallbackMessage); - } - const data = (await response.json()) as TenantPackagesResponse; - const packages = Array.isArray(data.data) ? data.data.map(normalizeTenantPackage) : []; - const activePackage = data.active_package ? normalizeTenantPackage(data.active_package) : null; - return { packages, activePackage }; + return cachedFetch( + CacheKeys.packages, + async () => { + const response = await fetchTenantPackagesEndpoint(); + if (!response.ok) { + const payload = await safeJson(response); + const fallbackMessage = i18n.t('common:errors.generic', 'Etwas ist schiefgelaufen. Bitte versuche es erneut.'); + emitApiErrorEvent({ message: fallbackMessage, status: response.status }); + console.error('[API] Failed to load tenant packages', response.status, payload); + throw new Error(fallbackMessage); + } + const data = (await response.json()) as TenantPackagesResponse; + const packages = Array.isArray(data.data) ? data.data.map(normalizeTenantPackage) : []; + const activePackage = data.active_package ? normalizeTenantPackage(data.active_package) : null; + return { packages, activePackage }; + }, + DEFAULT_CACHE_TTL * 5, + options?.force === true, + ); } export type NotificationPreferenceResponse = { @@ -1651,3 +1676,67 @@ export async function removeEventMember(eventIdentifier: number | string, member throw new Error('Failed to remove member'); } } +type CacheEntry = { + value?: T; + expiresAt: number; + promise?: Promise; +}; + +const tenantApiCache = new Map>(); +const DEFAULT_CACHE_TTL = 60_000; + +const CacheKeys = { + dashboard: 'tenant:dashboard', + events: 'tenant:events', + packages: 'tenant:packages', +} as const; + +function cachedFetch( + key: string, + fetcher: () => Promise, + ttl: number = DEFAULT_CACHE_TTL, + force = false, +): Promise { + if (force) { + tenantApiCache.delete(key); + } + + const now = Date.now(); + const existing = tenantApiCache.get(key) as CacheEntry | undefined; + + if (!force && existing) { + if (existing.promise) { + return existing.promise; + } + + if (existing.value !== undefined && existing.expiresAt > now) { + return Promise.resolve(existing.value); + } + } + + const promise = fetcher() + .then((value) => { + tenantApiCache.set(key, { value, expiresAt: Date.now() + ttl }); + return value; + }) + .catch((error) => { + tenantApiCache.delete(key); + throw error; + }); + + tenantApiCache.set(key, { value: existing?.value, expiresAt: existing?.expiresAt ?? 0, promise }); + + return promise; +} + +export function invalidateTenantApiCache(keys?: string | string[]): void { + if (!keys) { + tenantApiCache.clear(); + return; + } + + const entries = Array.isArray(keys) ? keys : [keys]; + for (const key of entries) { + tenantApiCache.delete(key); + } +} diff --git a/resources/js/admin/auth/context.tsx b/resources/js/admin/auth/context.tsx index a11ae88..3c68580 100644 --- a/resources/js/admin/auth/context.tsx +++ b/resources/js/admin/auth/context.tsx @@ -1,14 +1,14 @@ import React from 'react'; +import { useQueryClient } from '@tanstack/react-query'; import { authorizedFetch, - clearOAuthSession, clearTokens, - completeOAuthCallback, - loadTokens, + isAuthError, + loadToken, registerAuthFailureHandler, - startOAuthFlow, + storePersonalAccessToken, } from './tokens'; -import { ADMIN_DEFAULT_AFTER_LOGIN_PATH, ADMIN_LOGIN_PATH } from '../constants'; +import { invalidateTenantApiCache } from '../api'; export type AuthStatus = 'loading' | 'authenticated' | 'unauthenticated'; @@ -18,30 +18,74 @@ export interface TenantProfile { name?: string; slug?: string; email?: string | null; - event_credits_balance?: number; + event_credits_balance?: number | null; [key: string]: unknown; } interface AuthContextValue { status: AuthStatus; user: TenantProfile | null; - login: (redirectPath?: string) => void; - logout: (options?: { redirect?: string }) => void; - completeLogin: (params: URLSearchParams) => Promise; refreshProfile: () => Promise; + logout: (options?: { redirect?: string }) => Promise; + applyToken: (token: string, abilities: string[]) => Promise; } const AuthContext = React.createContext(undefined); +function getCsrfToken(): string | undefined { + const meta = document.querySelector('meta[name="csrf-token"]') as HTMLMetaElement | null; + return meta?.content; +} + +async function exchangeSessionForToken(): Promise<{ token: string; abilities: string[] } | null> { + const csrf = getCsrfToken(); + + try { + const response = await fetch('/api/v1/tenant-auth/exchange', { + method: 'POST', + headers: { + 'Accept': 'application/json', + 'X-Requested-With': 'XMLHttpRequest', + ...(csrf ? { 'X-CSRF-TOKEN': csrf } : {}), + }, + credentials: 'same-origin', + }); + + if (!response.ok) { + return null; + } + + const data = (await response.json()) as { token: string; abilities?: string[] }; + + if (!data?.token) { + return null; + } + + return { + token: data.token, + abilities: Array.isArray(data.abilities) ? data.abilities : [], + }; + } catch (error) { + if (import.meta.env.DEV) { + console.warn('[Auth] Session exchange failed', error); + } + return null; + } +} + export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { const [status, setStatus] = React.useState('loading'); const [user, setUser] = React.useState(null); + const queryClient = useQueryClient(); + const profileQueryKey = React.useMemo(() => ['tenantProfile'], []); const handleAuthFailure = React.useCallback(() => { clearTokens(); + invalidateTenantApiCache(); + queryClient.removeQueries({ queryKey: profileQueryKey }); setUser(null); setStatus('unauthenticated'); - }, []); + }, [profileQueryKey, queryClient]); React.useEffect(() => { const unsubscribe = registerAuthFailureHandler(handleAuthFailure); @@ -49,92 +93,133 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children }, [handleAuthFailure]); const refreshProfile = React.useCallback(async () => { + setStatus('loading'); + try { - const response = await authorizedFetch('/api/v1/tenant/me'); - if (!response.ok) { - throw new Error('Failed to load profile'); - } - const profile = (await response.json()) as TenantProfile; - setUser(profile); + const data = await queryClient.fetchQuery({ + queryKey: profileQueryKey, + queryFn: async () => { + const response = await authorizedFetch('/api/v1/tenant-auth/me', { + method: 'GET', + }); + + if (!response.ok) { + throw new Error('Failed to fetch tenant profile'); + } + + return (await response.json()) as { + user: TenantProfile | null; + tenant?: Record | null; + abilities: string[]; + }; + }, + staleTime: 1000 * 60 * 5, + cacheTime: 1000 * 60 * 30, + retry: 1, + }); + + const composed: TenantProfile | null = data.user && data.tenant + ? { ...data.user, ...data.tenant } + : data.user; + + setUser(composed ?? null); setStatus('authenticated'); } catch (error) { console.error('[Auth] Failed to refresh profile', error); - handleAuthFailure(); + + if (isAuthError(error)) { + handleAuthFailure(); + } else { + setStatus('unauthenticated'); + } + throw error; } - }, [handleAuthFailure]); + }, [handleAuthFailure, profileQueryKey, queryClient]); + + const applyToken = React.useCallback(async (token: string, abilities: string[]) => { + storePersonalAccessToken(token, abilities); + await refreshProfile(); + }, [refreshProfile]); React.useEffect(() => { - const searchParams = new URLSearchParams(window.location.search); - if (searchParams.has('reset-auth') || window.location.pathname === ADMIN_LOGIN_PATH) { - clearTokens(); - clearOAuthSession(); - setUser(null); - setStatus('unauthenticated'); - } + let cancelled = false; - const tokens = loadTokens(); - if (!tokens) { - setUser(null); - setStatus('unauthenticated'); - return; - } + const bootstrap = async () => { + const stored = loadToken(); + if (stored) { + try { + await refreshProfile(); + return; + } catch (error) { + if (import.meta.env.DEV) { + console.warn('[Auth] Stored token bootstrap failed', error); + } + } + } - refreshProfile().catch(() => { - // refreshProfile already handled failures. + const exchanged = await exchangeSessionForToken(); + if (cancelled) { + return; + } + + if (exchanged) { + await applyToken(exchanged.token, exchanged.abilities); + return; + } + + handleAuthFailure(); + }; + + bootstrap().catch((error) => { + if (import.meta.env.DEV) { + console.error('[Auth] Failed to bootstrap authentication', error); + } + handleAuthFailure(); }); - }, [handleAuthFailure, refreshProfile]); - const login = React.useCallback((redirectPath?: string) => { - const sanitizedTarget = redirectPath && redirectPath.trim() !== '' ? redirectPath : ADMIN_DEFAULT_AFTER_LOGIN_PATH; - const target = sanitizedTarget.startsWith('/') ? sanitizedTarget : `/${sanitizedTarget}`; - startOAuthFlow(target); - }, []); + return () => { + cancelled = true; + }; + }, [applyToken, handleAuthFailure, refreshProfile]); const logout = React.useCallback(async ({ redirect }: { redirect?: string } = {}) => { try { - const csrf = (document.querySelector('meta[name="csrf-token"]') as HTMLMetaElement | null)?.content; - await fetch('/logout', { + await authorizedFetch('/api/v1/tenant-auth/logout', { method: 'POST', - headers: { - 'X-Requested-With': 'XMLHttpRequest', - ...(csrf ? { 'X-CSRF-TOKEN': csrf } : {}), - }, - credentials: 'same-origin', }); } catch (error) { if (import.meta.env.DEV) { - console.warn('[Auth] Failed to notify backend about logout', error); + console.warn('[Auth] API logout failed', error); } } finally { - clearTokens(); - clearOAuthSession(); - setUser(null); - setStatus('unauthenticated'); + try { + const csrf = getCsrfToken(); + await fetch('/logout', { + method: 'POST', + headers: { + 'X-Requested-With': 'XMLHttpRequest', + ...(csrf ? { 'X-CSRF-TOKEN': csrf } : {}), + }, + credentials: 'same-origin', + }); + } catch (error) { + if (import.meta.env.DEV) { + console.warn('[Auth] Session logout failed', error); + } + } + + handleAuthFailure(); + if (redirect) { window.location.href = redirect; } } - }, []); - - const completeLogin = React.useCallback( - async (params: URLSearchParams) => { - setStatus('loading'); - try { - const redirectTarget = await completeOAuthCallback(params); - await refreshProfile(); - return redirectTarget; - } catch (error) { - handleAuthFailure(); - throw error; - } - }, - [handleAuthFailure, refreshProfile] - ); + }, [handleAuthFailure]); const value = React.useMemo( - () => ({ status, user, login, logout, completeLogin, refreshProfile }), - [status, user, login, logout, completeLogin, refreshProfile] + () => ({ status, user, refreshProfile, logout, applyToken }), + [status, user, refreshProfile, logout, applyToken] ); return {children}; diff --git a/resources/js/admin/auth/tokens.ts b/resources/js/admin/auth/tokens.ts index c672a79..bd40d25 100644 --- a/resources/js/admin/auth/tokens.ts +++ b/resources/js/admin/auth/tokens.ts @@ -1,42 +1,151 @@ -import { generateCodeChallenge, generateCodeVerifier, generateState } from './pkce'; -import { decodeStoredTokens } from './utils'; -import { ADMIN_AUTH_CALLBACK_PATH } from '../constants'; +const TOKEN_STORAGE_KEY = 'tenant_admin.token.v1'; +const TOKEN_SESSION_KEY = 'tenant_admin.token.session.v1'; -const TOKEN_STORAGE_KEY = 'tenant_oauth_tokens.v1'; -const CODE_VERIFIER_KEY = 'tenant_oauth_code_verifier'; -const STATE_KEY = 'tenant_oauth_state'; -const REDIRECT_KEY = 'tenant_oauth_redirect'; -const TOKEN_ENDPOINT = '/api/v1/oauth/token'; -const AUTHORIZE_ENDPOINT = '/api/v1/oauth/authorize'; -const SCOPES = (import.meta.env.VITE_OAUTH_SCOPES as string | undefined) ?? 'tenant:read tenant:write'; - -function getClientId(): string { - const clientId = import.meta.env.VITE_OAUTH_CLIENT_ID as string | undefined; - if (!clientId) { - throw new Error('VITE_OAUTH_CLIENT_ID is not configured'); - } - return clientId; -} - -function buildRedirectUri(): string { - return new URL(ADMIN_AUTH_CALLBACK_PATH, window.location.origin).toString(); -} +export type StoredToken = { + accessToken: string; + abilities: string[]; + issuedAt: number; +}; export class AuthError extends Error { - constructor(public code: 'unauthenticated' | 'unauthorized' | 'invalid_state' | 'token_exchange_failed', message?: string) { + constructor(public code: 'unauthenticated' | 'unauthorized', message?: string) { super(message ?? code); this.name = 'AuthError'; } } -export function isAuthError(value: unknown): value is AuthError { - return value instanceof AuthError; +let cachedToken: StoredToken | null = null; + +function decodeStoredToken(raw: string | null): StoredToken | null { + if (!raw) { + return null; + } + + try { + const parsed = JSON.parse(raw) as StoredToken; + if (!parsed || typeof parsed.accessToken !== 'string') { + return null; + } + + return { + accessToken: parsed.accessToken, + abilities: Array.isArray(parsed.abilities) ? parsed.abilities : [], + issuedAt: typeof parsed.issuedAt === 'number' ? parsed.issuedAt : Date.now(), + } satisfies StoredToken; + } catch (error) { + if (import.meta.env.DEV) { + console.warn('[Auth] Failed to decode stored token', error); + } + return null; + } +} + +function readTokenFromStorage(): StoredToken | null { + if (typeof window === 'undefined') { + return null; + } + + try { + const sessionValue = window.sessionStorage.getItem(TOKEN_SESSION_KEY); + const fromSession = decodeStoredToken(sessionValue); + if (fromSession) { + return fromSession; + } + } catch (error) { + if (import.meta.env.DEV) { + console.warn('[Auth] Failed to read session stored token', error); + } + } + + try { + const persistentValue = window.localStorage.getItem(TOKEN_STORAGE_KEY); + return decodeStoredToken(persistentValue); + } catch (error) { + if (import.meta.env.DEV) { + console.warn('[Auth] Failed to read persisted token', error); + } + return null; + } +} + +export function loadToken(): StoredToken | null { + if (cachedToken) { + return cachedToken; + } + + const stored = readTokenFromStorage(); + cachedToken = stored; + + return stored; +} + +function persistToken(token: StoredToken): void { + if (typeof window === 'undefined') { + cachedToken = token; + return; + } + + const serialized = JSON.stringify(token); + + try { + window.localStorage.setItem(TOKEN_STORAGE_KEY, serialized); + } catch (error) { + if (import.meta.env.DEV) { + console.warn('[Auth] Failed to persist tenant token to localStorage', error); + } + } + + try { + window.sessionStorage.setItem(TOKEN_SESSION_KEY, serialized); + } catch (error) { + if (import.meta.env.DEV) { + console.warn('[Auth] Failed to persist tenant token to sessionStorage', error); + } + } + + cachedToken = token; +} + +export function storePersonalAccessToken(accessToken: string, abilities: string[]): StoredToken { + const stored: StoredToken = { + accessToken, + abilities, + issuedAt: Date.now(), + }; + + persistToken(stored); + + return stored; +} + +export function clearTokens(): void { + cachedToken = null; + + if (typeof window === 'undefined') { + return; + } + + try { + window.localStorage.removeItem(TOKEN_STORAGE_KEY); + } catch (error) { + if (import.meta.env.DEV) { + console.warn('[Auth] Failed to remove stored tenant token', error); + } + } + + try { + window.sessionStorage.removeItem(TOKEN_SESSION_KEY); + } catch (error) { + if (import.meta.env.DEV) { + console.warn('[Auth] Failed to remove session tenant token', error); + } + } } type AuthFailureHandler = () => void; const authFailureHandlers = new Set(); -function notifyAuthFailure() { +function notifyAuthFailure(): void { authFailureHandlers.forEach((handler) => { try { handler(); @@ -53,214 +162,30 @@ export function registerAuthFailureHandler(handler: AuthFailureHandler): () => v }; } -export interface StoredTokens { - accessToken: string; - refreshToken: string; - expiresAt: number; - scope?: string; - clientId?: string; -} - -export interface TokenResponse { - access_token: string; - refresh_token: string; - expires_in: number; - token_type: string; - scope?: string; -} - -export function loadTokens(): StoredTokens | null { - const raw = localStorage.getItem(TOKEN_STORAGE_KEY); - const stored = decodeStoredTokens(raw); - if (!stored) { - return null; - } - - if (!stored.accessToken || !stored.refreshToken || !stored.expiresAt) { - clearTokens(); - return null; - } - - return stored; -} - -export function saveTokens(response: TokenResponse, clientId: string = getClientId()): StoredTokens { - const expiresAt = Date.now() + Math.max(response.expires_in - 30, 0) * 1000; - const stored: StoredTokens = { - accessToken: response.access_token, - refreshToken: response.refresh_token, - expiresAt, - scope: response.scope, - clientId, - }; - localStorage.setItem(TOKEN_STORAGE_KEY, JSON.stringify(stored)); - return stored; -} - -export function clearTokens(): void { - localStorage.removeItem(TOKEN_STORAGE_KEY); -} - -export async function ensureAccessToken(): Promise { - const tokens = loadTokens(); - if (!tokens) { - notifyAuthFailure(); - throw new AuthError('unauthenticated', 'No tokens available'); - } - - if (Date.now() < tokens.expiresAt) { - return tokens.accessToken; - } - - return refreshAccessToken(tokens); -} - -async function refreshAccessToken(tokens: StoredTokens): Promise { - const clientId = tokens.clientId ?? getClientId(); - - if (!tokens.refreshToken) { - notifyAuthFailure(); - throw new AuthError('unauthenticated', 'Missing refresh token'); - } - - const params = new URLSearchParams({ - grant_type: 'refresh_token', - refresh_token: tokens.refreshToken, - client_id: clientId, - }); - - const response = await fetch(TOKEN_ENDPOINT, { - method: 'POST', - headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, - body: params, - }); - - if (!response.ok) { - console.warn('[Auth] Refresh token request failed', response.status); - notifyAuthFailure(); - throw new AuthError('unauthenticated', 'Refresh token invalid'); - } - - const data = (await response.json()) as TokenResponse; - const stored = saveTokens(data, clientId); - return stored.accessToken; +export function isAuthError(value: unknown): value is AuthError { + return value instanceof AuthError; } export async function authorizedFetch(input: RequestInfo | URL, init: RequestInit = {}): Promise { - const token = await ensureAccessToken(); + const stored = loadToken(); + if (!stored) { + notifyAuthFailure(); + throw new AuthError('unauthenticated', 'No active tenant admin token'); + } + const headers = new Headers(init.headers); - headers.set('Authorization', `Bearer ${token}`); + headers.set('Authorization', `Bearer ${stored.accessToken}`); if (!headers.has('Accept')) { headers.set('Accept', 'application/json'); } const response = await fetch(input, { ...init, headers }); + if (response.status === 401) { + clearTokens(); notifyAuthFailure(); - throw new AuthError('unauthorized', 'Access token rejected'); + throw new AuthError('unauthorized', 'Token rejected by API'); } return response; } - -export async function startOAuthFlow(redirectPath?: string): Promise { - const verifier = generateCodeVerifier(); - const state = generateState(); - const challenge = await generateCodeChallenge(verifier); - - sessionStorage.setItem(CODE_VERIFIER_KEY, verifier); - sessionStorage.setItem(STATE_KEY, state); - localStorage.setItem(CODE_VERIFIER_KEY, verifier); - localStorage.setItem(STATE_KEY, state); - if (redirectPath) { - sessionStorage.setItem(REDIRECT_KEY, redirectPath); - localStorage.setItem(REDIRECT_KEY, redirectPath); - } - - if (import.meta.env.DEV) { - console.debug('[Auth] PKCE store', { state, verifier, redirectPath }); - } - - const params = new URLSearchParams({ - response_type: 'code', - client_id: getClientId(), - redirect_uri: buildRedirectUri(), - scope: SCOPES, - state, - code_challenge: challenge, - code_challenge_method: 'S256', - }); - - window.location.href = `${AUTHORIZE_ENDPOINT}?${params.toString()}`; -} - -export async function completeOAuthCallback(params: URLSearchParams): Promise { - if (params.get('error')) { - throw new AuthError('token_exchange_failed', params.get('error_description') ?? params.get('error') ?? 'OAuth error'); - } - - const code = params.get('code'); - const returnedState = params.get('state'); - const verifier = sessionStorage.getItem(CODE_VERIFIER_KEY) ?? localStorage.getItem(CODE_VERIFIER_KEY); - const expectedState = sessionStorage.getItem(STATE_KEY) ?? localStorage.getItem(STATE_KEY); - - if (import.meta.env.DEV) { - console.debug('[Auth] PKCE debug', { returnedState, expectedState, hasVerifier: !!verifier, params: params.toString() }); - } - - if (!code || !verifier || !returnedState || !expectedState || returnedState !== expectedState) { - clearOAuthSession(); - notifyAuthFailure(); - throw new AuthError('invalid_state', 'PKCE state mismatch'); - } - - sessionStorage.removeItem(CODE_VERIFIER_KEY); - sessionStorage.removeItem(STATE_KEY); - localStorage.removeItem(CODE_VERIFIER_KEY); - localStorage.removeItem(STATE_KEY); - - const clientId = getClientId(); - - const body = new URLSearchParams({ - grant_type: 'authorization_code', - code, - client_id: clientId, - redirect_uri: buildRedirectUri(), - code_verifier: verifier, - }); - - const response = await fetch(TOKEN_ENDPOINT, { - method: 'POST', - headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, - body, - }); - - if (!response.ok) { - clearOAuthSession(); - console.error('[Auth] Authorization code exchange failed', response.status); - notifyAuthFailure(); - throw new AuthError('token_exchange_failed', 'Failed to exchange authorization code'); - } - - const data = (await response.json()) as TokenResponse; - saveTokens(data, clientId); - - const redirectTarget = sessionStorage.getItem(REDIRECT_KEY); - if (redirectTarget) { - sessionStorage.removeItem(REDIRECT_KEY); - localStorage.removeItem(REDIRECT_KEY); - } else { - localStorage.removeItem(REDIRECT_KEY); - } - - return redirectTarget; -} - -export function clearOAuthSession(): void { - sessionStorage.removeItem(CODE_VERIFIER_KEY); - sessionStorage.removeItem(STATE_KEY); - sessionStorage.removeItem(REDIRECT_KEY); - localStorage.removeItem(CODE_VERIFIER_KEY); - localStorage.removeItem(STATE_KEY); - localStorage.removeItem(REDIRECT_KEY); -} diff --git a/resources/js/admin/components/AdminLayout.tsx b/resources/js/admin/components/AdminLayout.tsx index 3b972a7..af054c2 100644 --- a/resources/js/admin/components/AdminLayout.tsx +++ b/resources/js/admin/components/AdminLayout.tsx @@ -19,6 +19,7 @@ import { } from 'lucide-react'; import { LanguageSwitcher } from './LanguageSwitcher'; import { registerApiErrorListener } from '../lib/apiError'; +import { getDashboardSummary, getEvents, getTenantPackagesOverview } from '../api'; const navItems = [ { to: ADMIN_HOME_PATH, labelKey: 'navigation.dashboard', icon: LayoutDashboard, end: true }, @@ -37,6 +38,39 @@ interface AdminLayoutProps { export function AdminLayout({ title, subtitle, actions, children }: AdminLayoutProps) { const { t } = useTranslation('common'); + const prefetchedPathsRef = React.useRef>(new Set()); + + const prefetchers = React.useMemo(() => ({ + [ADMIN_HOME_PATH]: () => + Promise.all([ + getDashboardSummary(), + getEvents(), + getTenantPackagesOverview(), + ]).then(() => undefined), + [ADMIN_EVENTS_PATH]: () => getEvents().then(() => undefined), + [ADMIN_ENGAGEMENT_PATH]: () => getEvents().then(() => undefined), + [ADMIN_BILLING_PATH]: () => getTenantPackagesOverview().then(() => undefined), + [ADMIN_SETTINGS_PATH]: () => Promise.resolve(), + }), []); + + const triggerPrefetch = React.useCallback( + (path: string) => { + if (prefetchedPathsRef.current.has(path)) { + return; + } + + const runner = prefetchers[path as keyof typeof prefetchers]; + if (!runner) { + return; + } + + prefetchedPathsRef.current.add(path); + Promise.resolve(runner()).catch(() => { + prefetchedPathsRef.current.delete(path); + }); + }, + [prefetchers], + ); React.useEffect(() => { document.body.classList.add('tenant-admin-theme'); @@ -78,18 +112,21 @@ export function AdminLayout({ title, subtitle, actions, children }: AdminLayoutP {actions} -