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->errorResponse('Invalid request parameters', 400, $validator->errors()); } /** @var OAuthClient|null $client */ $client = OAuthClient::query() ->where('client_id', $request->string('client_id')) ->where('is_active', true) ->first(); if (! $client) { return $this->errorResponse('Invalid client', 401); } $allowedRedirects = (array) $client->redirect_uris; if (! in_array($request->redirect_uri, $allowedRedirects, true)) { return $this->errorResponse('Invalid redirect URI', 400); } $requestedScopes = $this->parseScopes($request->string('scope')); $availableScopes = (array) $client->scopes; if (! $this->scopesAreAllowed($requestedScopes, $availableScopes)) { return $this->errorResponse('Invalid scopes requested', 400); } $tenantId = $client->tenant_id ?? Tenant::query()->orderBy('id')->value('id'); if (! $tenantId) { return $this->errorResponse('Unable to resolve tenant for client', 500); } $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) $tenantId, '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, ]); return redirect()->away($redirectUrl); } /** * 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, 'refresh_token_id' => $storedRefreshToken->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, 'refresh_token_id' => $storedRefreshToken->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, 'refresh_token_id' => $storedRefreshToken->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, 'refresh_token_id' => $storedRefreshToken->id, '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); } if ($storedRefreshToken->client_id && $storedRefreshToken->client_id !== $request->client_id) { return $this->errorResponse('Refresh token does not match client', 400); } if ($storedRefreshToken->expires_at && $storedRefreshToken->expires_at->isPast()) { $storedRefreshToken->update(['revoked_at' => now()]); return $this->errorResponse('Refresh token expired', 400); } if (! Hash::check($refreshTokenSecret, $storedRefreshToken->token)) { return $this->errorResponse('Invalid refresh token', 400); } $storedIp = (string) ($storedRefreshToken->ip_address ?? ''); $currentIp = (string) ($request->ip() ?? ''); if ($storedIp !== '' && $currentIp !== '' && ! hash_equals($storedIp, $currentIp)) { $storedRefreshToken->update(['revoked_at' => now()]); 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, ]); return $this->errorResponse('Tenant not found', 404); } $scopes = $this->parseScopes($storedRefreshToken->scope); $storedRefreshToken->update(['revoked_at' => now()]); $tokenResponse = $this->issueTokenPair($tenant, $client, $scopes, $request); 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 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); RefreshToken::create([ 'id' => $refreshTokenId, 'tenant_id' => $tenant->id, 'client_id' => $client->client_id, 'token' => Hash::make($secret), 'access_token' => $accessTokenJti, 'expires_at' => $expiresAt, 'scope' => implode(' ', $scopes), 'ip_address' => $request->ip(), 'user_agent' => $request->userAgent(), ]); return $composite; } private function generateJWT( int $tenantId, string $clientId, array $scopes, string $type, int $expiresIn, string $jti, int $issuedAt, int $expiresAt ): string { [$publicKey, $privateKey] = $this->ensureKeysExist(); $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', self::TOKEN_HEADER_KID, ['kid' => self::TOKEN_HEADER_KID]); } private function ensureKeysExist(): array { $publicKeyPath = storage_path('app/public.key'); $privateKeyPath = storage_path('app/private.key'); $publicKey = @file_get_contents($publicKeyPath); $privateKey = @file_get_contents($privateKeyPath); if ($publicKey && $privateKey) { return [$publicKey, $privateKey]; } $this->generateKeyPair(); return [ file_get_contents($publicKeyPath), file_get_contents($privateKeyPath), ]; } private function generateKeyPair(): void { $config = [ 'digest_alg' => OPENSSL_ALGO_SHA256, 'private_key_bits' => 2048, 'private_key_type' => OPENSSL_KEYTYPE_RSA, ]; $res = openssl_pkey_new($config); if (! $res) { throw new \RuntimeException('Failed to generate key pair'); } openssl_pkey_export($res, $privKey); $pubKey = openssl_pkey_get_details($res); file_put_contents(storage_path('app/private.key'), $privKey); file_put_contents(storage_path('app/public.key'), $pubKey['key']); } 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 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 response()->json(['error' => 'Tenant not found'], 404); } $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()); } } }