all(), [ 'client_id' => 'required|string', 'redirect_uri' => 'required|url', '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()); } $client = OAuthClient::where('client_id', $request->client_id) ->where('is_active', true) ->first(); if (!$client) { return $this->errorResponse('Invalid client', 401); } // Validate redirect URI $redirectUris = is_array($client->redirect_uris) ? $client->redirect_uris : json_decode($client->redirect_uris, true); if (!in_array($request->redirect_uri, $redirectUris)) { return $this->errorResponse('Invalid redirect URI', 400); } // Validate scopes $requestedScopes = explode(' ', $request->scope); $availableScopes = is_array($client->scopes) ? array_keys($client->scopes) : []; $validScopes = array_intersect($requestedScopes, $availableScopes); if (count($validScopes) !== count($requestedScopes)) { return $this->errorResponse('Invalid scopes requested', 400); } // Generate authorization code (PKCE validated later) $code = Str::random(40); $codeId = Str::uuid(); // Store code in Redis with 5min TTL Cache::put("oauth_code:{$codeId}", [ 'code' => $code, 'client_id' => $request->client_id, 'redirect_uri' => $request->redirect_uri, 'scopes' => $validScopes, 'state' => $request->state ?? null, 'code_challenge' => $request->code_challenge, 'code_challenge_method' => $request->code_challenge_method, 'expires_at' => now()->addMinutes(5), ], now()->addMinutes(5)); // Save to DB for persistence OAuthCode::create([ 'id' => $codeId, 'code' => Hash::make($code), 'client_id' => $request->client_id, 'scopes' => json_encode($validScopes), 'state' => $request->state ?? null, 'expires_at' => now()->addMinutes(5), ]); $redirectUrl = $request->redirect_uri . '?' . http_build_query([ 'code' => $code, 'state' => $request->state ?? '', ]); return redirect($redirectUrl); } /** * Token endpoint - Code exchange for access/refresh tokens */ public function token(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', // PKCE ]); if ($validator->fails()) { return $this->errorResponse('Invalid request parameters', 400, $validator->errors()); } // Find the code in cache/DB $cachedCode = Cache::get($request->code); if (!$cachedCode) { return $this->errorResponse('Invalid authorization code', 400); } $codeId = array_search($request->code, Cache::get($request->code)['code'] ?? []); if (!$codeId) { $oauthCode = OAuthCode::where('code', 'LIKE', '%' . $request->code . '%')->first(); if (!$oauthCode || !Hash::check($request->code, $oauthCode->code)) { return $this->errorResponse('Invalid authorization code', 400); } $cachedCode = $oauthCode->toArray(); $codeId = $oauthCode->id; } // Validate client $client = OAuthClient::where('client_id', $request->client_id)->first(); if (!$client || !$client->is_active) { return $this->errorResponse('Invalid client', 401); } // PKCE validation if ($cachedCode['code_challenge_method'] === 'S256') { $expectedChallenge = $this->base64url_encode(hash('sha256', $request->code_verifier, true)); if (!hash_equals($expectedChallenge, $cachedCode['code_challenge'])) { return $this->errorResponse('Invalid code verifier', 400); } } else { if (!hash_equals($request->code_verifier, $cachedCode['code'])) { return $this->errorResponse('Invalid code verifier', 400); } } // Validate redirect URI if ($request->redirect_uri !== $cachedCode['redirect_uri']) { return $this->errorResponse('Invalid redirect URI', 400); } // Code used - delete/invalidate Cache::forget("oauth_code:{$codeId}"); OAuthCode::where('id', $codeId)->delete(); // Create tenant token (assuming client is tied to tenant - adjust as needed) $tenant = Tenant::where('id', $client->tenant_id ?? 1)->first(); // Default tenant or from client if (!$tenant) { return $this->errorResponse('Tenant not found', 404); } // Generate JWT Access Token (RS256) $privateKey = file_get_contents(storage_path('app/private.key')); // Ensure keys exist if (!$privateKey) { $this->generateKeyPair(); $privateKey = file_get_contents(storage_path('app/private.key')); } $accessToken = $this->generateJWT($tenant->id, $cachedCode['scopes'], 'access', 3600); // 1h $refreshTokenId = Str::uuid(); $refreshToken = Str::random(60); // Store refresh token RefreshToken::create([ 'id' => $refreshTokenId, 'token' => Hash::make($refreshToken), 'tenant_id' => $tenant->id, 'client_id' => $request->client_id, 'scopes' => json_encode($cachedCode['scopes']), 'expires_at' => now()->addDays(30), 'ip_address' => $request->ip(), ]); return response()->json([ 'token_type' => 'Bearer', 'access_token' => $accessToken, 'refresh_token' => $refreshToken, 'expires_in' => 3600, 'scope' => implode(' ', $cachedCode['scopes']), ]); } /** * Get tenant info */ public function me(Request $request) { $user = $request->user(); if (!$user) { return $this->errorResponse('Unauthenticated', 401); } $tenant = $user->tenant; if (!$tenant) { return $this->errorResponse('Tenant not found', 404); } return response()->json([ 'tenant_id' => $tenant->id, 'name' => $tenant->name, 'slug' => $tenant->slug, 'event_credits_balance' => $tenant->event_credits_balance, 'subscription_tier' => $tenant->subscription_tier, 'subscription_expires_at' => $tenant->subscription_expires_at, 'features' => $tenant->features, 'scopes' => $request->user()->token()->abilities, ]); } /** * Generate JWT token */ private function generateJWT($tenantId, $scopes, $type, $expiresIn) { $payload = [ 'iss' => url('/'), 'aud' => 'fotospiel-api', 'iat' => time(), 'nbf' => time(), 'exp' => time() + $expiresIn, 'sub' => $tenantId, 'scopes' => $scopes, 'type' => $type, ]; $publicKey = file_get_contents(storage_path('app/public.key')); $privateKey = file_get_contents(storage_path('app/private.key')); if (!$publicKey || !$privateKey) { $this->generateKeyPair(); $publicKey = file_get_contents(storage_path('app/public.key')); $privateKey = file_get_contents(storage_path('app/private.key')); } return JWT::encode($payload, $privateKey, 'RS256', null, null, null, ['kid' => 'fotospiel-jwt']); } /** * Generate RSA key pair for JWT */ private function generateKeyPair() { $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 \Exception('Failed to generate key pair'); } // Get private key openssl_pkey_export($res, $privateKey); file_put_contents(storage_path('app/private.key'), $privateKey); // Get public key $pubKey = openssl_pkey_get_details($res); file_put_contents(storage_path('app/public.key'), $pubKey["key"]); } /** * Error response helper */ private function errorResponse($message, $status = 400, $errors = null) { $response = ['error' => $message]; if ($errors) { $response['errors'] = $errors; } return response()->json($response, $status); } /** * Helper function for base64url encoding */ private function base64url_encode($data) { return rtrim(strtr(base64_encode($data), '+/', '-_'), '='); } /** * Stripe Connect OAuth - Start connection */ public function stripeConnect(Request $request) { $tenant = $request->user()->tenant; 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('/admin')->with('error', 'Stripe connection failed: ' . $error); } if (!$code || !$state) { return redirect('/admin')->with('error', 'Invalid callback parameters'); } // Validate state $sessionState = session('stripe_state'); if (!hash_equals($state, $sessionState)) { return redirect('/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(), true); if (!isset($tokenData['stripe_user_id'])) { return redirect('/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('/admin')->with('success', 'Stripe account connected successfully'); } catch (\Exception $e) { Log::error('Stripe OAuth error: ' . $e->getMessage()); return redirect('/admin')->with('error', 'Connection error: ' . $e->getMessage()); } } }