- Implemented `KekaController` to manage OAuth flows for Keka HR, including connection, callback, and disconnection. - Added required routes, middleware updates, and session handling for Keka integration. - Enhanced dashboard UI to display Keka connection status and provide seamless connect/disconnect options. - Included validation and configuration for Keka Portal URL in connected apps. - Developed feature tests to ensure reliable authentication and token handling workflows for Keka.
244 lines
7.9 KiB
PHP
244 lines
7.9 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Http\Controllers;
|
|
|
|
use App\Models\{ConnectedApp, OauthToken};
|
|
use App\Services\UserAppAccessService;
|
|
use Illuminate\Http\{RedirectResponse, Request};
|
|
use Illuminate\Support\Facades\Log;
|
|
|
|
class KekaController extends Controller
|
|
{
|
|
/**
|
|
* Step 1: Redirect the user to Keka's external login page.
|
|
*/
|
|
public function connect(): RedirectResponse
|
|
{
|
|
$user = auth()->user();
|
|
if (! $user) {
|
|
abort(403, 'Unauthorized');
|
|
}
|
|
|
|
$activeServices = app(UserAppAccessService::class)->getActiveServices($user);
|
|
$hasKekaAccess = $activeServices->contains(fn ($service) => 'keka-hr' === $service->provider_slug);
|
|
|
|
if (! $hasKekaAccess) {
|
|
abort(403, 'You do not have permission to access the required application.');
|
|
}
|
|
|
|
$app = ConnectedApp::whereHas('provider', function ($query): void {
|
|
$query->where('slug', 'keka-hr');
|
|
})->first();
|
|
|
|
if (! $app) {
|
|
abort(404, 'Keka HR app registration not found.');
|
|
}
|
|
|
|
$kekaUrl = data_get($app->settings, 'keka.keka_url') ?? data_get($app->settings, 'keka_url') ?? 'https://sentientgeeks.keka.com';
|
|
$provider = data_get($app->settings, 'keka.provider') ?? data_get($app->settings, 'provider') ?? 'Office365';
|
|
|
|
$redirectUrl = $this->getKekaExternalLoginUrl((string) $kekaUrl, (string) $provider);
|
|
|
|
Log::info('Redirecting user to Keka external login page.', [
|
|
'user_id' => $user->id,
|
|
'keka_url' => $kekaUrl,
|
|
'redirect_url' => $redirectUrl,
|
|
]);
|
|
|
|
return redirect($redirectUrl);
|
|
}
|
|
|
|
/**
|
|
* Parse the configured Keka URL to build the correct external login URL.
|
|
*/
|
|
private function getKekaExternalLoginUrl(string $configuredUrl, string $provider): string
|
|
{
|
|
$parsed = parse_url($configuredUrl);
|
|
$host = $parsed['host'] ?? 'login.keka.com';
|
|
|
|
$query = [];
|
|
if (isset($parsed['query'])) {
|
|
parse_str($parsed['query'], $query);
|
|
}
|
|
|
|
$companyId = $query['companyId'] ?? $query['companyid'] ?? null;
|
|
|
|
if (! $companyId && 'login.keka.com' !== $host) {
|
|
$parts = explode('.', $host);
|
|
if (count($parts) >= 2) {
|
|
$companyId = $parts[0];
|
|
}
|
|
}
|
|
|
|
$buildQuery = [
|
|
'provider' => $provider,
|
|
];
|
|
|
|
// if ($companyId) {
|
|
// $buildQuery['companyId'] = $companyId;
|
|
// }
|
|
|
|
$queryString = http_build_query($buildQuery);
|
|
|
|
return "https://login.keka.com/Account/ExternalLogin?{$queryString}";
|
|
}
|
|
|
|
/**
|
|
* Step 2: Handle the Microsoft callback redirection.
|
|
* We record/store the tokens in the database, and then pass the code/state to Keka.
|
|
*/
|
|
public function callback(Request $request)
|
|
{
|
|
$user = auth()->user();
|
|
if (! $user) {
|
|
abort(403, 'Unauthorized');
|
|
}
|
|
|
|
$activeServices = app(UserAppAccessService::class)->getActiveServices($user);
|
|
$hasKekaAccess = $activeServices->contains(fn ($service) => 'keka-hr' === $service->provider_slug);
|
|
|
|
if (! $hasKekaAccess) {
|
|
abort(403, 'You do not have permission to access the required application.');
|
|
}
|
|
|
|
$code = $request->input('code');
|
|
$state = $request->input('state');
|
|
|
|
if (empty($code)) {
|
|
return redirect()->route('dashboard.user')->with('keka_error', 'Keka connection failed: Missing code.');
|
|
}
|
|
|
|
// Retrieve Keka App configurations
|
|
$app = ConnectedApp::whereHas('provider', function ($query): void {
|
|
$query->where('slug', 'keka-hr');
|
|
})->first();
|
|
|
|
if (! $app) {
|
|
abort(404, 'Keka HR app registration not found.');
|
|
}
|
|
|
|
$kekaUrl = data_get($app->settings, 'keka.keka_url') ?? data_get($app->settings, 'keka_url') ?? 'https://sentientgeeks.keka.com';
|
|
$callbackPath = data_get($app->settings, 'keka.callback_path') ?? 'signin-oidc';
|
|
$callbackUrl = $this->getKekaCallbackUrl((string) $kekaUrl, (string) $callbackPath);
|
|
|
|
// Store token details (Option B: copy user's existing Microsoft token or save code/placeholder)
|
|
$microsoftToken = $user->oauthToken('microsoft');
|
|
|
|
if ($microsoftToken) {
|
|
$accessToken = $microsoftToken->access_token;
|
|
$refreshToken = $microsoftToken->refresh_token;
|
|
$tokenType = $microsoftToken->token_type;
|
|
$scopes = $microsoftToken->scopes;
|
|
$expiresAt = $microsoftToken->expires_at;
|
|
} else {
|
|
$accessToken = (string) $code;
|
|
$refreshToken = 'keka-placeholder-refresh';
|
|
$tokenType = 'Bearer';
|
|
$scopes = 'openid profile email';
|
|
$expiresAt = now()->addHour();
|
|
}
|
|
|
|
OauthToken::updateOrCreate(
|
|
[
|
|
'user_id' => $user->id,
|
|
'provider' => 'keka',
|
|
],
|
|
[
|
|
'access_token' => $accessToken,
|
|
'refresh_token' => $refreshToken,
|
|
'token_type' => $tokenType,
|
|
'scopes' => $scopes,
|
|
'expires_at' => $expiresAt,
|
|
]
|
|
);
|
|
|
|
Log::info('Keka tokens stored successfully. Passing OIDC response to Keka.', [
|
|
'user_id' => $user->id,
|
|
'callback_url' => $callbackUrl,
|
|
'method' => $request->method(),
|
|
]);
|
|
|
|
if ($request->isMethod('post')) {
|
|
$inputs = '';
|
|
foreach ($request->all() as $key => $value) {
|
|
$safeKey = htmlspecialchars((string) $key, ENT_QUOTES, 'UTF-8');
|
|
$safeValue = htmlspecialchars((string) $value, ENT_QUOTES, 'UTF-8');
|
|
$inputs .= "<input type=\"hidden\" name=\"{$safeKey}\" value=\"{$safeValue}\" />\n";
|
|
}
|
|
|
|
$html = <<<HTML
|
|
<!DOCTYPE html>
|
|
<html>
|
|
<head>
|
|
<title>Forwarding to Keka...</title>
|
|
</head>
|
|
<body onload="document.forms[0].submit()">
|
|
<form method="post" action="{$callbackUrl}">
|
|
{$inputs}
|
|
<noscript>
|
|
<p>JavaScript is required to connect to Keka. Click the button below to continue.</p>
|
|
<input type="submit" value="Continue" />
|
|
</noscript>
|
|
</form>
|
|
</body>
|
|
</html>
|
|
HTML;
|
|
|
|
return response($html);
|
|
}
|
|
|
|
$queryParams = $request->query();
|
|
$queryString = http_build_query($queryParams);
|
|
|
|
return redirect("{$callbackUrl}?{$queryString}");
|
|
}
|
|
|
|
/**
|
|
* Parse the configured Keka URL to construct the callback URL.
|
|
*/
|
|
private function getKekaCallbackUrl(string $configuredUrl, string $callbackPath): string
|
|
{
|
|
$parsed = parse_url($configuredUrl);
|
|
$host = $parsed['host'] ?? 'login.keka.com';
|
|
|
|
if ('login.keka.com' !== $host) {
|
|
$parts = explode('.', $host);
|
|
if (count($parts) >= 2 && 'keka' === $parts[1]) {
|
|
$host = 'login.keka.com';
|
|
}
|
|
}
|
|
|
|
$scheme = $parsed['scheme'] ?? 'https';
|
|
|
|
return "{$scheme}://{$host}/".mb_ltrim($callbackPath, '/');
|
|
}
|
|
|
|
/**
|
|
* Disconnect/Revoke the Keka integration for the user.
|
|
*/
|
|
public function disconnect(): RedirectResponse
|
|
{
|
|
$user = auth()->user();
|
|
if (! $user) {
|
|
abort(403, 'Unauthorized');
|
|
}
|
|
|
|
$activeServices = app(UserAppAccessService::class)->getActiveServices($user);
|
|
$hasKekaAccess = $activeServices->contains(fn ($service) => 'keka-hr' === $service->provider_slug);
|
|
|
|
if (! $hasKekaAccess) {
|
|
abort(403, 'You do not have permission to access the required application.');
|
|
}
|
|
|
|
$user->oauthToken('keka')?->delete();
|
|
|
|
Log::info('Keka integration disconnected for user.', [
|
|
'user_id' => $user->id,
|
|
]);
|
|
|
|
return redirect()->route('dashboard.user')->with('keka_success', 'Keka HR disconnected.');
|
|
}
|
|
}
|