diff --git a/app/Http/Controllers/KekaController.php b/app/Http/Controllers/KekaController.php new file mode 100644 index 0000000..99451b9 --- /dev/null +++ b/app/Http/Controllers/KekaController.php @@ -0,0 +1,243 @@ +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 .= "\n"; + } + + $html = << + + + Forwarding to Keka... + + +
+ {$inputs} + +
+ + +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.'); + } +} diff --git a/app/Livewire/Forms/StoreConnectedAppFrom.php b/app/Livewire/Forms/StoreConnectedAppFrom.php index 8e956ca..61f6b7c 100644 --- a/app/Livewire/Forms/StoreConnectedAppFrom.php +++ b/app/Livewire/Forms/StoreConnectedAppFrom.php @@ -30,6 +30,8 @@ class StoreConnectedAppFrom extends Form public array $samlAttributes = []; + public string $kekaUrl = ''; + protected ConnectedAppService $service; public function boot(ConnectedAppService $service): void @@ -65,6 +67,11 @@ public function rules(): array } } + $provider = \App\Models\ConnectionProvider::query()->find($this->providerId); + if ($provider && 'keka-hr' === $provider->slug) { + $rules['kekaUrl'] = 'required|url'; + } + return $rules; } @@ -82,6 +89,7 @@ public function set(ConnectedAppData $data): void $this->samlAcsUrl = $data->settings['saml']['acs_url'] ?? ''; $this->samlNameIdFormat = $data->settings['saml']['nameid_format'] ?? 'persistent'; $this->samlNameIdSource = $data->settings['saml']['nameid_source'] ?? 'immutable_id'; + $this->kekaUrl = $data->settings['keka_url'] ?? $data->settings['keka']['keka_url'] ?? ''; $this->samlAttributes = []; $attributes = $data->settings['saml']['attributes'] ?? []; diff --git a/bootstrap/app.php b/bootstrap/app.php index b645e1c..ded1166 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -11,5 +11,9 @@ commands: __DIR__.'/../routes/console.php', health: '/up', ) - ->withMiddleware(function (Middleware $middleware): void {}) + ->withMiddleware(function (Middleware $middleware): void { + $middleware->validateCsrfTokens(except: [ + 'keka/callback', + ]); + }) ->withExceptions(function (Exceptions $exceptions): void {})->create(); diff --git a/resources/views/pages/connected-apps/⚡create.blade.php b/resources/views/pages/connected-apps/⚡create.blade.php index 0042854..202801b 100644 --- a/resources/views/pages/connected-apps/⚡create.blade.php +++ b/resources/views/pages/connected-apps/⚡create.blade.php @@ -77,6 +77,16 @@ public function isSaml(): bool return $protocol && strtolower($protocol->name) === "saml"; } + #[Computed] + public function isKekaProvider(): bool + { + $provider = $this->providers()->firstWhere( + "id", + (int) $this->form->providerId, + ); + return $provider && $provider->slug === "keka-hr"; + } + public function save(bool $goBack = true): void { $this->form->validate(); @@ -98,6 +108,15 @@ public function save(bool $goBack = true): void "attributes" => $attributes, ], ]; + } elseif ($this->isKekaProvider()) { + $settings = [ + "keka_url" => $this->form->kekaUrl, + "keka" => [ + "keka_url" => $this->form->kekaUrl, + "callback_path" => "signin-oidc", + "provider" => "Office365", + ], + ]; } $data = new ConnectAppRequest( @@ -150,7 +169,7 @@ public function goBack(): void /> @endif + @if($this->isKekaProvider) +
+

Keka Configuration

+ +
+ @endif +
Cancel Save & Add Another diff --git a/resources/views/pages/connected-apps/⚡edit.blade.php b/resources/views/pages/connected-apps/⚡edit.blade.php index 22f9752..b346f36 100644 --- a/resources/views/pages/connected-apps/⚡edit.blade.php +++ b/resources/views/pages/connected-apps/⚡edit.blade.php @@ -79,6 +79,16 @@ public function isSaml(): bool return $protocol && strtolower($protocol->name) === "saml"; } + #[Computed] + public function isKekaProvider(): bool + { + $provider = $this->providers()->firstWhere( + "id", + (int) $this->form->providerId, + ); + return $provider && $provider->slug === "keka-hr"; + } + /** * @returns DataCollection */ @@ -113,6 +123,15 @@ public function save(): void "attributes" => $attributes, ], ]; + } elseif ($this->isKekaProvider()) { + $settings = [ + "keka_url" => $this->form->kekaUrl, + "keka" => [ + "keka_url" => $this->form->kekaUrl, + "callback_path" => "signin-oidc", + "provider" => "Office365", + ], + ]; } $serviceData = new ConnectAppRequest( @@ -157,7 +176,7 @@ public function removeSamlAttribute(int $index): void /> isSaml) @endif + + @if($this->isKekaProvider) +
+

Keka Configuration

+ +
+ @endif
diff --git a/resources/views/pages/dashboards/⚡user.blade.php b/resources/views/pages/dashboards/⚡user.blade.php index a3f77b1..3b3a729 100644 --- a/resources/views/pages/dashboards/⚡user.blade.php +++ b/resources/views/pages/dashboards/⚡user.blade.php @@ -40,6 +40,14 @@ public function mount(): void if (session("entra_error")) { $this->error(session("entra_error")); } + + if (session("keka_success")) { + $this->success(session("keka_success")); + } + + if (session("keka_error")) { + $this->error(session("keka_error")); + } } #[Computed] @@ -67,6 +75,20 @@ public function isEntraConnected(): bool return $token !== null && !$token->isExpired(); } + + #[Computed] + public function kekaToken(): ?OauthToken + { + return auth()->user()?->oauthToken('keka'); + } + + #[Computed] + public function isKekaConnected(): bool + { + $token = $this->kekaToken(); + + return $token !== null && !$token->isExpired(); + } }; ?> @@ -189,6 +211,58 @@ class="btn-primary flex-1" @endif + + @if($service->protocol_name === 'url' && $service->provider_slug === 'keka-hr') +
+
+ Keka Integration + @if($this->isKekaConnected) + + Connected + + @else + + Not Connected + + @endif +
+ +
+ @if($this->isKekaConnected) + + +
+ @csrf + @method('DELETE') + + + @else + + @endif +
+
+ @endif
@@ -230,6 +304,24 @@ class="btn-sm btn-circle btn-soft btn-primary" tooltip="Launch Service" /> @endif + @elseif($service->provider_slug === 'keka-hr') + @if($this->isKekaConnected) + + @else + + @endif @else name('disconnect'); }); +Route::middleware(['auth', 'verified'])->prefix('keka')->name('keka.')->group(function (): void { + Route::get('connect', [KekaController::class, 'connect'])->name('connect'); + Route::match(['get', 'post'], 'callback', [KekaController::class, 'callback'])->name('callback'); + Route::delete('disconnect', [KekaController::class, 'disconnect'])->name('disconnect'); +}); + require __DIR__.'/settings.php'; diff --git a/scratch_keka.php b/scratch_keka.php new file mode 100644 index 0000000..2e60500 --- /dev/null +++ b/scratch_keka.php @@ -0,0 +1,14 @@ +make(Illuminate\Contracts\Console\Kernel::class)->bootstrap(); +$kekaApp = App\Models\ConnectedApp::whereHas('provider', function ($query): void { + $query->where('slug', 'keka-hr'); +})->first(); +if ($kekaApp) { + print_r($kekaApp->toArray()); +} else { + echo "No Keka HR app found\n"; +} diff --git a/tests/Feature/KekaOAuthTest.php b/tests/Feature/KekaOAuthTest.php new file mode 100644 index 0000000..ad5580a --- /dev/null +++ b/tests/Feature/KekaOAuthTest.php @@ -0,0 +1,264 @@ +where('name', 'url')->first(); + if (! $protocol) { + $protocol = new App\Models\ConnectionProtocol(); + $protocol->name = 'url'; + $protocol->save(); + } + + $provider = App\Models\ConnectionProvider::query()->where('slug', 'keka-hr')->first(); + if (! $provider) { + $provider = new App\Models\ConnectionProvider(); + $provider->name = 'Keka HR'; + $provider->slug = 'keka-hr'; + $provider->save(); + } + + $status = App\Models\ConnectionStatus::query()->where('name', 'connected')->first(); + if (! $status) { + $status = new App\Models\ConnectionStatus(); + $status->name = 'connected'; + $status->save(); + } + + $app = App\Models\ConnectedApp::query()->create([ + 'name' => 'Keka HR Portal', + 'slug' => 'keka-hr-portal', + 'connection_protocol_id' => $protocol->id, + 'connection_provider_id' => $provider->id, + 'connection_status_id' => $status->id, + 'settings' => [ + 'keka' => [ + 'keka_url' => 'https://sentientgeeks.keka.com', + 'callback_path' => 'signin-oidc', + 'provider' => 'Office365', + ], + ], + ]); + + $role = App\Models\Role::findOrCreate('Keka User'); + $role->update(['priority' => 10]); + $role->apps()->attach($app->id, ['duration' => now()->addYear()->toDateTimeString()]); + + $user->assignRole($role); +} + +it('redirects unauthenticated users to login when connecting', function (): void { + $this->get(route('keka.connect')) + ->assertRedirect(route('login')); +}); + +it('returns 403 forbidden if user does not have permission for keka-hr app', function (): void { + $user = User::factory()->create(); + + $this->actingAs($user) + ->get(route('keka.connect')) + ->assertStatus(403); +}); + +it('redirects to Keka login page on connect when authorized', function (): void { + $user = User::factory()->create(); + assignKekaHrAccess($user); + + $response = $this->actingAs($user) + ->get(route('keka.connect')); + + $response->assertRedirect('https://login.keka.com/Account/ExternalLogin?provider=Office365&companyId=sentientgeeks'); +}); + +it('stores token and redirects to Keka callback on successful callback when authorized', function (): void { + $user = User::factory()->create(); + assignKekaHrAccess($user); + + // Create a microsoft token first to check if callback copies it + OauthToken::create([ + 'user_id' => $user->id, + 'provider' => 'microsoft', + 'access_token' => 'ms-access-token', + 'refresh_token' => 'ms-refresh-token', + 'token_type' => 'Bearer', + 'expires_at' => now()->addHour(), + ]); + + $response = $this->actingAs($user) + ->get(route('keka.callback', [ + 'code' => 'keka-code-value', + 'state' => 'keka-state-value', + ])); + + $response->assertRedirect('https://login.keka.com/signin-oidc?code=keka-code-value&state=keka-state-value'); + + $this->assertDatabaseHas('oauth_tokens', [ + 'user_id' => $user->id, + 'provider' => 'keka', + 'access_token' => 'ms-access-token', + 'refresh_token' => 'ms-refresh-token', + ]); +}); + +it('stores placeholder token and redirects to Keka callback when no microsoft token exists', function (): void { + $user = User::factory()->create(); + assignKekaHrAccess($user); + + $response = $this->actingAs($user) + ->get(route('keka.callback', [ + 'code' => 'keka-code-value2', + 'state' => 'keka-state-value2', + ])); + + $response->assertRedirect('https://login.keka.com/signin-oidc?code=keka-code-value2&state=keka-state-value2'); + + $this->assertDatabaseHas('oauth_tokens', [ + 'user_id' => $user->id, + 'provider' => 'keka', + 'access_token' => 'keka-code-value2', + ]); +}); + +it('redirects to dashboard with error on callback if code is missing', function (): void { + $user = User::factory()->create(); + assignKekaHrAccess($user); + + $response = $this->actingAs($user) + ->get(route('keka.callback', [ + 'state' => 'keka-state-value', + ])); + + $response->assertRedirect(route('dashboard.user')); + $response->assertSessionHas('keka_error'); +}); + +it('disconnects and removes the token when authorized', function (): void { + $user = User::factory()->create(); + assignKekaHrAccess($user); + + OauthToken::create([ + 'user_id' => $user->id, + 'provider' => 'keka', + 'access_token' => 'fake-keka-token', + 'refresh_token' => 'fake-keka-refresh', + 'token_type' => 'Bearer', + 'expires_at' => now()->addHour(), + ]); + + $response = $this->actingAs($user) + ->delete(route('keka.disconnect')); + + $response->assertRedirect(route('dashboard.user')); + $response->assertSessionHas('keka_success', 'Keka HR disconnected.'); + + $this->assertDatabaseMissing('oauth_tokens', [ + 'user_id' => $user->id, + 'provider' => 'keka', + ]); +}); + +it('requires a valid kekaUrl when creating a Keka HR connected app', function (): void { + $this->seed(Database\Seeders\ConnectionProtocolSeeder::class); + $this->seed(Database\Seeders\ConnectionProviderSeeder::class); + $this->seed(Database\Seeders\ConnectionStatusSeeder::class); + + $admin = User::factory()->create(); + $admin->assignRole(App\Models\Role::findOrCreate('Admin')); + + $protocol = App\Models\ConnectionProtocol::query()->where('name', 'url')->firstOrFail(); + $provider = App\Models\ConnectionProvider::query()->where('slug', 'keka-hr')->firstOrFail(); + $status = App\Models\ConnectionStatus::query()->where('name', 'connected')->firstOrFail(); + + $this->actingAs($admin); + + // Test validation failure + Livewire::test('pages::connected-apps.create') + ->set('form.name', 'My Keka App') + ->set('form.protocolId', $protocol->id) + ->set('form.providerId', $provider->id) + ->set('form.statusId', $status->id) + ->set('form.kekaUrl', 'not-a-valid-url') + ->call('save') + ->assertHasErrors(['form.kekaUrl' => 'url']); + + // Test validation success + Livewire::test('pages::connected-apps.create') + ->set('form.name', 'My Keka App') + ->set('form.protocolId', $protocol->id) + ->set('form.providerId', $provider->id) + ->set('form.statusId', $status->id) + ->set('form.kekaUrl', 'https://sentientgeeks.keka.com') + ->call('save') + ->assertHasNoErrors(); + + $this->assertDatabaseHas('connected_apps', [ + 'name' => 'My Keka App', + 'connection_protocol_id' => $protocol->id, + 'connection_provider_id' => $provider->id, + ]); + + $app = App\Models\ConnectedApp::query()->where('name', 'My Keka App')->firstOrFail(); + expect(data_get($app->settings, 'keka_url'))->toBe('https://sentientgeeks.keka.com'); + expect(data_get($app->settings, 'keka.provider'))->toBe('Office365'); +}); + +it('successfully edits a Keka HR connected app', function (): void { + $this->seed(Database\Seeders\ConnectionProtocolSeeder::class); + $this->seed(Database\Seeders\ConnectionProviderSeeder::class); + $this->seed(Database\Seeders\ConnectionStatusSeeder::class); + + $admin = User::factory()->create(); + $admin->assignRole(App\Models\Role::findOrCreate('Admin')); + + $protocol = App\Models\ConnectionProtocol::query()->where('name', 'url')->firstOrFail(); + $provider = App\Models\ConnectionProvider::query()->where('slug', 'keka-hr')->firstOrFail(); + $status = App\Models\ConnectionStatus::query()->where('name', 'connected')->firstOrFail(); + + $app = App\Models\ConnectedApp::query()->create([ + 'name' => 'My Keka App', + 'slug' => 'my-keka-app', + 'connection_protocol_id' => $protocol->id, + 'connection_provider_id' => $provider->id, + 'connection_status_id' => $status->id, + 'settings' => [ + 'keka_url' => 'https://old.keka.com', + 'keka' => [ + 'keka_url' => 'https://old.keka.com', + 'provider' => 'OpenIdConnect', + ], + ], + ]); + + $this->actingAs($admin); + + Livewire::test('pages::connected-apps.edit', ['id' => $app->id]) + ->set('form.kekaUrl', 'https://new.keka.com') + ->call('save') + ->assertHasNoErrors(); + + $app->refresh(); + expect(data_get($app->settings, 'keka_url'))->toBe('https://new.keka.com'); + expect(data_get($app->settings, 'keka.provider'))->toBe('Office365'); +}); + +it('forwards POST callback response via an auto-submitting HTML form', function (): void { + $user = User::factory()->create(); + assignKekaHrAccess($user); + + $response = $this->actingAs($user) + ->post(route('keka.callback'), [ + 'code' => 'post-code-value', + 'state' => 'post-state-value', + 'session_state' => 'session-state-value', + ]); + + $response->assertStatus(200); + $response->assertSee('action="https://login.keka.com/signin-oidc"', false); + $response->assertSee('name="code" value="post-code-value"', false); + $response->assertSee('name="state" value="post-state-value"', false); + $response->assertSee('name="session_state" value="session-state-value"', false); +});