session()->pull('tenant_onboard_state'); $tenantKey = $request->string('tenant')->toString(); $state = $request->string('state')->toString(); $tenantIdentifier = $tenantKey ?: $this->parseState($state); if ($expectedState && $expectedState !== $state) { abort(ResponseAlias::HTTP_FORBIDDEN, 'Invalid consent state'); } abort_if(empty($tenantIdentifier), 404); $tenant = Tenant::withTrashed() ->forTenant($tenantIdentifier) ->first(); if ($tenant?->trashed()) { $tenant->restore(); } if (! $tenant) { $tenant = Tenant::create([ 'tenant_id' => $tenantIdentifier, 'name' => 'New Tenant', 'app_client_id' => config('graph.client_id'), 'app_client_secret' => config('graph.client_secret'), 'app_status' => 'pending', ]); } $error = $request->string('error')->toString() ?: null; $consentGranted = $request->has('admin_consent') ? filter_var($request->input('admin_consent'), FILTER_VALIDATE_BOOLEAN) : null; $status = match (true) { $error !== null => 'error', $consentGranted === false => 'consent_denied', $consentGranted === true => 'ok', default => 'pending', }; $tenant->update([ 'app_status' => $status, 'app_notes' => $error, ]); $auditLogger->log( tenant: $tenant, action: 'tenant.consent.callback', context: [ 'metadata' => [ 'status' => $status, 'state' => $state, 'error' => $error, 'consent' => $consentGranted, ], ], status: $status === 'ok' ? 'success' : 'error', resourceType: 'tenant', resourceId: (string) $tenant->id, ); return view('admin-consent-callback', [ 'tenant' => $tenant, 'status' => $status, 'error' => $error, 'consentGranted' => $consentGranted, ]); } private function handleAuthorizationCodeFlow( Request $request, AuditLogger $auditLogger, TenantConfigService $configService, TenantPermissionService $permissionService, GraphClientInterface $graphClient ): View { $expectedState = $request->session()->pull('tenant_onboard_state'); if ($expectedState && $expectedState !== $request->string('state')->toString()) { abort(ResponseAlias::HTTP_FORBIDDEN, 'Invalid consent state'); } $redirectUri = route('admin.consent.callback'); $token = $this->exchangeAuthorizationCode( code: $request->string('code')->toString(), redirectUri: $redirectUri ); $tenantId = $token['tenant_id'] ?? null; abort_if(empty($tenantId), 500, 'Tenant ID missing from token'); /** @var Tenant|null $tenant */ $tenant = Tenant::withTrashed() ->forTenant($tenantId) ->first(); if ($tenant?->trashed()) { $tenant->restore(); } if (! $tenant) { $tenant = Tenant::create([ 'tenant_id' => $tenantId, 'name' => 'New Tenant', 'app_client_id' => config('graph.client_id'), 'app_client_secret' => config('graph.client_secret'), 'app_status' => 'pending', ]); } $orgResponse = $graphClient->getOrganization([ 'tenant' => $tenant->graphTenantId(), 'client_id' => $tenant->app_client_id, 'client_secret' => $tenant->app_client_secret, ]); if ($orgResponse->successful()) { $org = $orgResponse->data ?? []; $tenant->update([ 'name' => $org['displayName'] ?? $tenant->name, 'domain' => $org['verifiedDomains'][0]['name'] ?? $tenant->domain, ]); } $configResult = $configService->testConnectivity($tenant); $permissionService->compare($tenant); $status = $configResult['success'] ? 'ok' : 'error'; $tenant->update([ 'app_status' => $status, 'app_notes' => $configResult['error_message'], ]); $auditLogger->log( tenant: $tenant, action: 'tenant.consent.callback', context: [ 'metadata' => [ 'status' => $status, 'error' => $configResult['error_message'], 'from' => 'authorization_code', ], ], status: $status === 'ok' ? 'success' : 'error', resourceType: 'tenant', resourceId: (string) $tenant->id, ); return view('admin-consent-callback', [ 'tenant' => $tenant, 'status' => $status, 'error' => $configResult['error_message'], 'consentGranted' => $status === 'ok', ]); } /** * @return array{access_token:string,id_token:string,tenant_id:?string} */ private function exchangeAuthorizationCode(string $code, string $redirectUri): array { $response = Http::asForm()->post('https://login.microsoftonline.com/common/oauth2/v2.0/token', [ 'client_id' => config('graph.client_id'), 'client_secret' => config('graph.client_secret'), 'code' => $code, 'grant_type' => 'authorization_code', 'redirect_uri' => $redirectUri, 'scope' => 'https://graph.microsoft.com/.default offline_access openid profile', ]); if ($response->failed()) { abort(ResponseAlias::HTTP_BAD_GATEWAY, 'Failed to exchange code for token'); } $body = $response->json(); $idToken = $body['id_token'] ?? null; $tenantId = $this->parseTenantIdFromToken($idToken); return [ 'access_token' => $body['access_token'] ?? '', 'id_token' => $idToken ?? '', 'tenant_id' => $tenantId, ]; } private function parseTenantIdFromToken(?string $token): ?string { if (! $token || ! str_contains($token, '.')) { return null; } $parts = explode('.', $token); $payload = json_decode(base64_decode(strtr($parts[1], '-_', '+/')) ?: '[]', true); return $payload['tid'] ?? $payload['tenant'] ?? null; } private function parseState(?string $state): ?string { if (empty($state)) { return null; } if (str_contains($state, '|')) { [, $value] = explode('|', $state, 2); return $value; } return $state; } }