hooks in config/services.php/.env.example, and updated wizard steps/controllers to store session payloads, attach packages, and surface localized success/error states. - Retooled payment handling for both Stripe and PayPal, adding richer status management in CheckoutController/ PayPalController, fallback flows in the wizard’s PaymentStep.tsx, and fresh feature tests for intent creation, webhooks, and the wizard CTA. - Introduced a consent-aware Matomo analytics stack: new consent context, cookie-banner UI, useAnalytics/ useCtaExperiment hooks, and MatomoTracker component, then instrumented marketing pages (Home, Packages, Checkout) with localized copy and experiment tracking. - Polished package presentation across marketing UIs by centralizing formatting in PresentsPackages, surfacing localized description tables/placeholders, tuning badges/layouts, and syncing guest/marketing translations. - Expanded docs & reference material (docs/prp/*, TODOs, public gallery overview) and added a Playwright smoke test for the hero CTA while reconciling outstanding checklist items.
655 lines
22 KiB
PHP
655 lines
22 KiB
PHP
<?php
|
|
|
|
namespace App\Http\Controllers;
|
|
|
|
use App\Models\OAuthClient;
|
|
use App\Models\OAuthCode;
|
|
use App\Models\RefreshToken;
|
|
use App\Models\Tenant;
|
|
use App\Models\TenantToken;
|
|
use Illuminate\Http\Request;
|
|
use Illuminate\Support\Arr;
|
|
use Illuminate\Support\Facades\Cache;
|
|
use Illuminate\Support\Facades\File;
|
|
use Illuminate\Support\Facades\Hash;
|
|
use Illuminate\Support\Facades\Validator;
|
|
use Illuminate\Support\Str;
|
|
use Firebase\JWT\JWT;
|
|
use GuzzleHttp\Client;
|
|
use Illuminate\Support\Facades\Log;
|
|
|
|
class OAuthController extends Controller
|
|
{
|
|
private const AUTH_CODE_TTL_MINUTES = 5;
|
|
private const ACCESS_TOKEN_TTL_SECONDS = 3600;
|
|
private const REFRESH_TOKEN_TTL_DAYS = 30;
|
|
private const LEGACY_TOKEN_HEADER_KID = 'fotospiel-jwt';
|
|
|
|
/**
|
|
* Authorize endpoint - PKCE flow
|
|
*/
|
|
public function authorize(Request $request)
|
|
{
|
|
$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->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 (config('oauth.refresh_tokens.enforce_ip_binding', true) && ! $this->ipMatches($storedIp, $currentIp)) {
|
|
$storedRefreshToken->update(['revoked_at' => now()]);
|
|
|
|
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,
|
|
]);
|
|
|
|
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 {
|
|
[$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 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());
|
|
}
|
|
}
|
|
}
|
|
|