Merge branch 'subhajit_keka_chnages'
This commit is contained in:
commit
19c2892569
@ -5,32 +5,28 @@
|
|||||||
namespace App\Http\Controllers;
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
use App\Models\OauthToken;
|
use App\Models\OauthToken;
|
||||||
|
use App\Services\UserAppAccessService;
|
||||||
use Illuminate\Http\{RedirectResponse, Request};
|
use Illuminate\Http\{RedirectResponse, Request};
|
||||||
use Illuminate\Support\Facades\Http;
|
use Illuminate\Support\Facades\Http;
|
||||||
use Illuminate\Support\Str;
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
class EntraController extends Controller
|
class EntraController extends Controller
|
||||||
{
|
{
|
||||||
// ── OAuth config from .env ────────────────────────────────────────────────
|
|
||||||
|
|
||||||
private string $tenantId;
|
|
||||||
|
|
||||||
private string $clientId;
|
|
||||||
|
|
||||||
private string $clientSecret;
|
|
||||||
|
|
||||||
private string $redirectUri;
|
|
||||||
|
|
||||||
private const PROVIDER = 'microsoft';
|
private const PROVIDER = 'microsoft';
|
||||||
|
|
||||||
private const SCOPES = 'openid profile email offline_access User.Read Mail.Read Mail.ReadWrite Mail.Send';
|
private const SCOPES = 'openid profile email offline_access User.Read Mail.Read Mail.ReadWrite Mail.Send';
|
||||||
|
|
||||||
|
private string $tenantId;
|
||||||
|
private string $clientId;
|
||||||
|
private string $clientSecret;
|
||||||
|
private string $redirectUri;
|
||||||
|
|
||||||
public function __construct()
|
public function __construct()
|
||||||
{
|
{
|
||||||
$this->tenantId = config('services.microsoft.tenant_id');
|
$this->tenantId = (string) config('services.microsoft.tenant_id');
|
||||||
$this->clientId = config('services.microsoft.client_id');
|
$this->clientId = (string) config('services.microsoft.client_id');
|
||||||
$this->clientSecret = config('services.microsoft.client_secret');
|
$this->clientSecret = (string) config('services.microsoft.client_secret');
|
||||||
$this->redirectUri = route('entra.callback');
|
$this->redirectUri = route('entra.callback');
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Step 1: Redirect user to Microsoft login ──────────────────────────────
|
// ── Step 1: Redirect user to Microsoft login ──────────────────────────────
|
||||||
@ -38,28 +34,43 @@ public function __construct()
|
|||||||
public function connect(): RedirectResponse
|
public function connect(): RedirectResponse
|
||||||
{
|
{
|
||||||
$user = auth()->user();
|
$user = auth()->user();
|
||||||
|
|
||||||
if (! $user) {
|
if (! $user) {
|
||||||
abort(403, 'Unauthorized');
|
abort(403, 'Unauthorized');
|
||||||
}
|
}
|
||||||
|
|
||||||
$activeServices = app(\App\Services\UserAppAccessService::class)->getActiveServices($user);
|
if (! $this->hasAzureAccess($user)) {
|
||||||
$hasAzureFdAccess = $activeServices->contains(fn ($service) => 'azure-fd' === $service->provider_slug);
|
|
||||||
|
|
||||||
if (! $hasAzureFdAccess) {
|
|
||||||
abort(403, 'You do not have permission to access the required application.');
|
abort(403, 'You do not have permission to access the required application.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Already connected and token still valid → open Outlook directly
|
||||||
|
$token = $user->oauthToken(self::PROVIDER);
|
||||||
|
|
||||||
|
if ($token && $token->expires_at->gt(now()->addMinutes(5))) {
|
||||||
|
return redirect('https://outlook.office.com/mail/');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Token exists but expired → try silent refresh first
|
||||||
|
if ($token && $token->refresh_token) {
|
||||||
|
if ($this->refreshToken($token)) {
|
||||||
|
return redirect('https://outlook.office.com/mail/');
|
||||||
|
}
|
||||||
|
// Refresh failed → delete and fall through to full login
|
||||||
|
$token->delete();
|
||||||
|
}
|
||||||
|
|
||||||
|
// No token or refresh failed → full Microsoft login
|
||||||
$state = Str::random(40);
|
$state = Str::random(40);
|
||||||
session(['oauth_state' => $state]);
|
session(['oauth_state' => $state]);
|
||||||
|
|
||||||
$query = http_build_query([
|
$query = http_build_query([
|
||||||
'client_id' => $this->clientId,
|
'client_id' => $this->clientId,
|
||||||
'response_type' => 'code',
|
'response_type' => 'code',
|
||||||
'redirect_uri' => $this->redirectUri,
|
'redirect_uri' => $this->redirectUri,
|
||||||
'response_mode' => 'query',
|
'response_mode' => 'query',
|
||||||
'scope' => self::SCOPES,
|
'scope' => self::SCOPES,
|
||||||
'state' => $state,
|
'state' => $state,
|
||||||
'prompt' => 'select_account',
|
'prompt' => 'select_account',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return redirect("https://login.microsoftonline.com/{$this->tenantId}/oauth2/v2.0/authorize?{$query}");
|
return redirect("https://login.microsoftonline.com/{$this->tenantId}/oauth2/v2.0/authorize?{$query}");
|
||||||
@ -70,75 +81,83 @@ public function connect(): RedirectResponse
|
|||||||
public function callback(Request $request): RedirectResponse
|
public function callback(Request $request): RedirectResponse
|
||||||
{
|
{
|
||||||
$user = auth()->user();
|
$user = auth()->user();
|
||||||
|
|
||||||
if (! $user) {
|
if (! $user) {
|
||||||
abort(403, 'Unauthorized');
|
abort(403, 'Unauthorized');
|
||||||
}
|
}
|
||||||
|
|
||||||
$activeServices = app(\App\Services\UserAppAccessService::class)->getActiveServices($user);
|
if (! $this->hasAzureAccess($user)) {
|
||||||
$hasAzureFdAccess = $activeServices->contains(fn ($service) => 'azure-fd' === $service->provider_slug);
|
|
||||||
|
|
||||||
if (! $hasAzureFdAccess) {
|
|
||||||
abort(403, 'You do not have permission to access the required application.');
|
abort(403, 'You do not have permission to access the required application.');
|
||||||
}
|
}
|
||||||
|
|
||||||
// CSRF state check
|
// CSRF state check
|
||||||
if ($request->state !== session('oauth_state')) {
|
if (! hash_equals((string) session('oauth_state'), (string) $request->state)) {
|
||||||
return redirect()->route('dashboard.user')->with('entra_error', 'Invalid OAuth state. Please try again.');
|
session()->forget('oauth_state');
|
||||||
|
return redirect()->route('dashboard.user')
|
||||||
|
->with('entra_error', 'Invalid OAuth state. Please try again.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
session()->forget('oauth_state');
|
||||||
|
|
||||||
if ($request->has('error')) {
|
if ($request->has('error')) {
|
||||||
return redirect()->route('dashboard.user')->with('entra_error', $request->error_description ?? 'Microsoft login failed.');
|
return redirect()->route('dashboard.user')
|
||||||
|
->with('entra_error', $request->error_description ?? 'Microsoft login failed.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $request->filled('code')) {
|
||||||
|
return redirect()->route('dashboard.user')
|
||||||
|
->with('entra_error', 'No authorization code received from Microsoft.');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Exchange authorization code for tokens
|
// Exchange authorization code for tokens
|
||||||
$response = Http::asForm()->post(
|
$response = Http::asForm()->post(
|
||||||
"https://login.microsoftonline.com/{$this->tenantId}/oauth2/v2.0/token",
|
"https://login.microsoftonline.com/{$this->tenantId}/oauth2/v2.0/token",
|
||||||
[
|
[
|
||||||
'client_id' => $this->clientId,
|
'client_id' => $this->clientId,
|
||||||
'client_secret' => $this->clientSecret,
|
'client_secret' => $this->clientSecret,
|
||||||
'code' => $request->code,
|
'code' => $request->code,
|
||||||
'redirect_uri' => $this->redirectUri,
|
'redirect_uri' => $this->redirectUri,
|
||||||
'grant_type' => 'authorization_code',
|
'grant_type' => 'authorization_code',
|
||||||
|
'scope' => self::SCOPES,
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
if ($response->failed()) {
|
if ($response->failed()) {
|
||||||
return redirect()->route('dashboard.user')->with('entra_error', 'Failed to retrieve tokens from Microsoft.');
|
return redirect()->route('dashboard.user')
|
||||||
|
->with('entra_error', $response->json('error_description', 'Failed to retrieve tokens from Microsoft.'));
|
||||||
}
|
}
|
||||||
|
|
||||||
$data = $response->json();
|
$data = $response->json();
|
||||||
|
|
||||||
// Persist token for this user (upsert so re-connecting just updates)
|
|
||||||
OauthToken::updateOrCreate(
|
OauthToken::updateOrCreate(
|
||||||
[
|
[
|
||||||
'user_id' => auth()->id(),
|
'user_id' => $user->id,
|
||||||
'provider' => self::PROVIDER,
|
'provider' => self::PROVIDER,
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
'access_token' => $data['access_token'],
|
'access_token' => $data['access_token'],
|
||||||
'refresh_token' => $data['refresh_token'] ?? null,
|
'refresh_token' => $data['refresh_token'] ?? null,
|
||||||
'token_type' => $data['token_type'] ?? 'Bearer',
|
'token_type' => $data['token_type'] ?? 'Bearer',
|
||||||
'scopes' => self::SCOPES,
|
'scopes' => self::SCOPES,
|
||||||
'expires_at' => now()->addSeconds($data['expires_in'] ?? 3600),
|
'expires_at' => now()->addSeconds((int) ($data['expires_in'] ?? 3600)),
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
return redirect()->route('dashboard.user')->with('entra_success', 'Microsoft connected successfully!');
|
return redirect()->route('dashboard.user')
|
||||||
|
->with('entra_success', 'Microsoft connected successfully!');
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Open Entra (refresh token silently if expired) ───────────────────────
|
// ── Open Outlook (silent refresh if expired) ──────────────────────────────
|
||||||
|
|
||||||
public function openEntra(): RedirectResponse
|
public function openEntra(): RedirectResponse
|
||||||
{
|
{
|
||||||
$user = auth()->user();
|
$user = auth()->user();
|
||||||
|
|
||||||
if (! $user) {
|
if (! $user) {
|
||||||
abort(403, 'Unauthorized');
|
abort(403, 'Unauthorized');
|
||||||
}
|
}
|
||||||
|
|
||||||
$activeServices = app(\App\Services\UserAppAccessService::class)->getActiveServices($user);
|
if (! $this->hasAzureAccess($user)) {
|
||||||
$hasAzureFdAccess = $activeServices->contains(fn ($service) => 'azure-fd' === $service->provider_slug);
|
|
||||||
|
|
||||||
if (! $hasAzureFdAccess) {
|
|
||||||
abort(403, 'You do not have permission to access the required application.');
|
abort(403, 'You do not have permission to access the required application.');
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -148,20 +167,20 @@ public function openEntra(): RedirectResponse
|
|||||||
return redirect()->route('entra.connect');
|
return redirect()->route('entra.connect');
|
||||||
}
|
}
|
||||||
|
|
||||||
// If token is expired, try to refresh silently
|
// Token still valid (5-min buffer)
|
||||||
if ($token->isExpired()) {
|
if ($token->expires_at->gt(now()->addMinutes(5))) {
|
||||||
$refreshed = $this->refreshToken($token);
|
return redirect('https://outlook.office.com/mail/');
|
||||||
|
|
||||||
if (! $refreshed) {
|
|
||||||
// Refresh failed — force re-login
|
|
||||||
$token->delete();
|
|
||||||
|
|
||||||
return redirect()->route('entra.connect');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Redirect to Outlook Web — SSO session handles silent sign-in
|
// Token expired — try silent refresh
|
||||||
return redirect('https://outlook.office.com/mail/');
|
if ($this->refreshToken($token)) {
|
||||||
|
return redirect('https://outlook.office.com/mail/');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Refresh failed — force full re-login
|
||||||
|
$token->delete();
|
||||||
|
|
||||||
|
return redirect()->route('entra.connect');
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Disconnect ────────────────────────────────────────────────────────────
|
// ── Disconnect ────────────────────────────────────────────────────────────
|
||||||
@ -169,23 +188,22 @@ public function openEntra(): RedirectResponse
|
|||||||
public function disconnect(): RedirectResponse
|
public function disconnect(): RedirectResponse
|
||||||
{
|
{
|
||||||
$user = auth()->user();
|
$user = auth()->user();
|
||||||
|
|
||||||
if (! $user) {
|
if (! $user) {
|
||||||
abort(403, 'Unauthorized');
|
abort(403, 'Unauthorized');
|
||||||
}
|
}
|
||||||
|
|
||||||
$activeServices = app(\App\Services\UserAppAccessService::class)->getActiveServices($user);
|
if (! $this->hasAzureAccess($user)) {
|
||||||
$hasAzureFdAccess = $activeServices->contains(fn ($service) => 'azure-fd' === $service->provider_slug);
|
|
||||||
|
|
||||||
if (! $hasAzureFdAccess) {
|
|
||||||
abort(403, 'You do not have permission to access the required application.');
|
abort(403, 'You do not have permission to access the required application.');
|
||||||
}
|
}
|
||||||
|
|
||||||
$user->oauthToken(self::PROVIDER)?->delete();
|
$user->oauthToken(self::PROVIDER)?->delete();
|
||||||
|
|
||||||
return redirect()->route('dashboard.user')->with('entra_success', 'Microsoft disconnected.');
|
return redirect()->route('dashboard.user')
|
||||||
|
->with('entra_success', 'Microsoft disconnected.');
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Internal: Refresh access token using refresh_token ───────────────────
|
// ── Internal: silent refresh ──────────────────────────────────────────────
|
||||||
|
|
||||||
private function refreshToken(OauthToken $token): bool
|
private function refreshToken(OauthToken $token): bool
|
||||||
{
|
{
|
||||||
@ -196,11 +214,11 @@ private function refreshToken(OauthToken $token): bool
|
|||||||
$response = Http::asForm()->post(
|
$response = Http::asForm()->post(
|
||||||
"https://login.microsoftonline.com/{$this->tenantId}/oauth2/v2.0/token",
|
"https://login.microsoftonline.com/{$this->tenantId}/oauth2/v2.0/token",
|
||||||
[
|
[
|
||||||
'client_id' => $this->clientId,
|
'client_id' => $this->clientId,
|
||||||
'client_secret' => $this->clientSecret,
|
'client_secret' => $this->clientSecret,
|
||||||
'refresh_token' => $token->refresh_token,
|
'refresh_token' => $token->refresh_token,
|
||||||
'grant_type' => 'refresh_token',
|
'grant_type' => 'refresh_token',
|
||||||
'scope' => self::SCOPES,
|
'scope' => self::SCOPES,
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -211,11 +229,20 @@ private function refreshToken(OauthToken $token): bool
|
|||||||
$data = $response->json();
|
$data = $response->json();
|
||||||
|
|
||||||
$token->update([
|
$token->update([
|
||||||
'access_token' => $data['access_token'],
|
'access_token' => $data['access_token'],
|
||||||
'refresh_token' => $data['refresh_token'] ?? $token->refresh_token,
|
'refresh_token' => $data['refresh_token'] ?? $token->refresh_token,
|
||||||
'expires_at' => now()->addSeconds($data['expires_in'] ?? 3600),
|
'expires_at' => now()->addSeconds((int) ($data['expires_in'] ?? 3600)),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Internal: permission check (DRY) ─────────────────────────────────────
|
||||||
|
|
||||||
|
private function hasAzureAccess(mixed $user): bool
|
||||||
|
{
|
||||||
|
$activeServices = app(UserAppAccessService::class)->getActiveServices($user);
|
||||||
|
|
||||||
|
return $activeServices->contains(fn ($service) => 'azure-fd' === $service->provider_slug);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@ -46,9 +46,15 @@ public function user(): BelongsTo
|
|||||||
return $this->belongsTo(User::class);
|
return $this->belongsTo(User::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
public function isExpired(): bool
|
public function isExpired(): bool
|
||||||
{
|
{
|
||||||
return $this->expires_at->isPast();
|
return $this->expires_at->lte(now()->addMinutes(5)); // 5-min buffer
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isValid(): bool
|
||||||
|
{
|
||||||
|
return ! $this->isExpired();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -4,90 +4,66 @@
|
|||||||
|
|
||||||
namespace App\Services;
|
namespace App\Services;
|
||||||
|
|
||||||
|
use App\Models\OauthToken;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use Illuminate\Support\Facades\Http;
|
use Illuminate\Support\Facades\Http;
|
||||||
use Illuminate\Support\Str;
|
use Illuminate\Support\Str;
|
||||||
use RuntimeException;
|
use RuntimeException;
|
||||||
|
|
||||||
/**
|
|
||||||
* Handles Microsoft OAuth (delegated, authorization code flow) for Keka-Outlook integration.
|
|
||||||
*
|
|
||||||
* Flow:
|
|
||||||
* - connect() -> build Microsoft login URL
|
|
||||||
* - handleCallback() -> exchange code for tokens, store on user
|
|
||||||
* - getValidToken() -> return a valid access token, refreshing silently if expired
|
|
||||||
* - disconnect() -> wipe stored tokens for the user
|
|
||||||
*/
|
|
||||||
final class KekaOutlookService
|
final class KekaOutlookService
|
||||||
{
|
{
|
||||||
|
private const PROVIDER = 'keka';
|
||||||
|
|
||||||
private string $clientId;
|
private string $clientId;
|
||||||
|
|
||||||
private string $clientSecret;
|
private string $clientSecret;
|
||||||
|
|
||||||
private string $tenantId;
|
private string $tenantId;
|
||||||
|
|
||||||
private string $redirectUri;
|
private string $redirectUri;
|
||||||
|
|
||||||
private array $scopes;
|
private array $scopes;
|
||||||
|
|
||||||
public function __construct()
|
public function __construct()
|
||||||
{
|
{
|
||||||
$this->clientId = (string) config('microsoft.client_id');
|
$this->clientId = (string) config('microsoft.client_id');
|
||||||
$this->clientSecret = (string) config('microsoft.client_secret');
|
$this->clientSecret = (string) config('microsoft.client_secret');
|
||||||
$this->tenantId = (string) config('microsoft.tenant_id');
|
$this->tenantId = (string) config('microsoft.tenant_id');
|
||||||
$this->redirectUri = (string) config('microsoft.keka_redirect_uri');
|
$this->redirectUri = (string) config('microsoft.keka_redirect_uri');
|
||||||
$this->scopes = config('microsoft.keka_scopes', [
|
$this->scopes = (array) config('microsoft.keka_scopes', [
|
||||||
'openid',
|
'openid', 'profile', 'email', 'offline_access', 'User.Read',
|
||||||
'profile',
|
|
||||||
'email',
|
|
||||||
'offline_access',
|
|
||||||
'User.Read',
|
|
||||||
'Mail.Read',
|
|
||||||
'Mail.ReadWrite',
|
|
||||||
'Mail.Send',
|
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Build the Microsoft login URL the user is redirected to.
|
|
||||||
* A random "state" value is generated and stored in the session to prevent CSRF.
|
|
||||||
*/
|
|
||||||
public function getAuthUrl(): string
|
public function getAuthUrl(): string
|
||||||
{
|
{
|
||||||
$state = Str::random(40);
|
$state = Str::random(40);
|
||||||
session(['keka_ms_oauth_state' => $state]);
|
session(['keka_ms_oauth_state' => $state]);
|
||||||
|
|
||||||
$query = http_build_query([
|
return 'https://login.microsoftonline.com/' . $this->tenantId . '/oauth2/v2.0/authorize?' . http_build_query([
|
||||||
'client_id' => $this->clientId,
|
'client_id' => $this->clientId,
|
||||||
'response_type' => 'code',
|
'response_type' => 'code',
|
||||||
'redirect_uri' => $this->redirectUri,
|
'redirect_uri' => $this->redirectUri,
|
||||||
'response_mode' => 'query',
|
'response_mode' => 'query',
|
||||||
'scope' => implode(' ', $this->scopes),
|
'scope' => implode(' ', $this->scopes),
|
||||||
'state' => $state,
|
'state' => $state,
|
||||||
// prompt=select_account forces account chooser on first connect.
|
'prompt' => 'select_account',
|
||||||
'prompt' => 'select_account',
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return "https://login.microsoftonline.com/{$this->tenantId}/oauth2/v2.0/authorize?{$query}";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Validate the "state" returned by Microsoft against the one stored in session.
|
|
||||||
*/
|
|
||||||
public function isValidState(?string $state): bool
|
public function isValidState(?string $state): bool
|
||||||
{
|
{
|
||||||
$expected = session('keka_ms_oauth_state');
|
$expected = session('keka_ms_oauth_state');
|
||||||
|
|
||||||
if (! $expected || ! $state) {
|
if (! $expected || ! $state) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
return hash_equals($expected, $state);
|
return hash_equals($expected, $state);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function isConnected(User $user): bool
|
||||||
|
{
|
||||||
|
return OauthToken::where('user_id', $user->id)
|
||||||
|
->where('provider', self::PROVIDER)
|
||||||
|
->exists();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Exchange the authorization code for access + refresh tokens and store them on the user.
|
|
||||||
*
|
|
||||||
* @return array{success: bool, message?: string}
|
* @return array{success: bool, message?: string}
|
||||||
*/
|
*/
|
||||||
public function handleCallback(User $user, string $code): array
|
public function handleCallback(User $user, string $code): array
|
||||||
@ -112,114 +88,84 @@ public function handleCallback(User $user, string $code): array
|
|||||||
}
|
}
|
||||||
|
|
||||||
$data = $response->json();
|
$data = $response->json();
|
||||||
|
|
||||||
$this->storeTokens($user, $data);
|
$this->storeTokens($user, $data);
|
||||||
|
|
||||||
// Fetch the connected mailbox's email address for display purposes.
|
|
||||||
$profile = Http::withToken($data['access_token'])
|
|
||||||
->get('https://graph.microsoft.com/v1.0/me');
|
|
||||||
|
|
||||||
if ($profile->successful()) {
|
|
||||||
$user->keka_ms_email = $profile->json('mail') ?? $profile->json('userPrincipalName');
|
|
||||||
$user->save();
|
|
||||||
}
|
|
||||||
|
|
||||||
session()->forget('keka_ms_oauth_state');
|
session()->forget('keka_ms_oauth_state');
|
||||||
|
|
||||||
return ['success' => true];
|
return ['success' => true];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Persist tokens + expiry on the user model.
|
|
||||||
*/
|
|
||||||
private function storeTokens(User $user, array $tokenData): void
|
|
||||||
{
|
|
||||||
$user->keka_ms_access_token = $tokenData['access_token'];
|
|
||||||
$user->keka_ms_token_expires_at = now()->addSeconds((int) $tokenData['expires_in']);
|
|
||||||
|
|
||||||
// Microsoft only returns refresh_token if 'offline_access' scope was granted.
|
|
||||||
// If a new one isn't returned on refresh, keep the existing one.
|
|
||||||
if (! empty($tokenData['refresh_token'])) {
|
|
||||||
$user->keka_ms_refresh_token = $tokenData['refresh_token'];
|
|
||||||
}
|
|
||||||
|
|
||||||
$user->save();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get a valid access token for the user.
|
* Returns a valid access token, silently refreshing if expired.
|
||||||
* Silently refreshes via refresh_token if the current access token has expired.
|
|
||||||
*
|
*
|
||||||
* @throws RuntimeException if the user has not connected or refresh fails.
|
* @throws \RuntimeException when not connected or refresh fails (caller must redirect to connect)
|
||||||
*/
|
*/
|
||||||
public function getValidToken(User $user): string
|
public function getValidToken(User $user): string
|
||||||
{
|
{
|
||||||
if (! $this->isConnected($user)) {
|
$record = OauthToken::where('user_id', $user->id)
|
||||||
throw new RuntimeException('User has not connected Keka/Outlook.');
|
->where('provider', self::PROVIDER)
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if (! $record) {
|
||||||
|
throw new \RuntimeException('Not connected.');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Still valid? (1 minute buffer)
|
// Valid with 5-minute buffer
|
||||||
if (
|
if ($record->expires_at->gt(now()->addMinutes(5))) {
|
||||||
$user->keka_ms_access_token
|
return $record->access_token;
|
||||||
&& $user->keka_ms_token_expires_at
|
|
||||||
&& now()->lt($user->keka_ms_token_expires_at->subMinute())
|
|
||||||
) {
|
|
||||||
return $user->keka_ms_access_token;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return $this->refreshToken($user);
|
// Expired — silent refresh
|
||||||
}
|
if (! $record->refresh_token) {
|
||||||
|
$record->delete();
|
||||||
|
throw new \RuntimeException('No refresh token. Please reconnect.');
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns true if the user has connected Keka/Outlook (has a refresh token stored).
|
|
||||||
*/
|
|
||||||
public function isConnected(User $user): bool
|
|
||||||
{
|
|
||||||
return ! empty($user->keka_ms_refresh_token);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Use the stored refresh_token to obtain a new access_token silently.
|
|
||||||
*
|
|
||||||
* @throws RuntimeException if Microsoft rejects the refresh token (user must reconnect).
|
|
||||||
*/
|
|
||||||
private function refreshToken(User $user): string
|
|
||||||
{
|
|
||||||
$response = Http::asForm()->post(
|
$response = Http::asForm()->post(
|
||||||
"https://login.microsoftonline.com/{$this->tenantId}/oauth2/v2.0/token",
|
"https://login.microsoftonline.com/{$this->tenantId}/oauth2/v2.0/token",
|
||||||
[
|
[
|
||||||
'client_id' => $this->clientId,
|
'client_id' => $this->clientId,
|
||||||
'client_secret' => $this->clientSecret,
|
'client_secret' => $this->clientSecret,
|
||||||
'grant_type' => 'refresh_token',
|
'grant_type' => 'refresh_token',
|
||||||
'refresh_token' => $user->keka_ms_refresh_token,
|
'refresh_token' => $record->refresh_token,
|
||||||
'scope' => implode(' ', $this->scopes),
|
'scope' => implode(' ', $this->scopes),
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
if (! $response->successful()) {
|
if (! $response->successful()) {
|
||||||
// Refresh token expired/revoked -> force user to reconnect.
|
$record->delete();
|
||||||
$this->disconnect($user);
|
throw new \RuntimeException('Session expired. Please reconnect Keka.');
|
||||||
|
|
||||||
throw new RuntimeException(
|
|
||||||
$response->json('error_description', 'Session expired. Please reconnect Keka.')
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$data = $response->json();
|
$data = $response->json();
|
||||||
$this->storeTokens($user, $data);
|
|
||||||
|
$record->update([
|
||||||
|
'access_token' => $data['access_token'],
|
||||||
|
'refresh_token' => $data['refresh_token'] ?? $record->refresh_token,
|
||||||
|
'expires_at' => now()->addSeconds((int) ($data['expires_in'] ?? 3600)),
|
||||||
|
]);
|
||||||
|
|
||||||
return $data['access_token'];
|
return $data['access_token'];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Disconnect: wipe stored tokens. User must go through login flow again to reconnect.
|
|
||||||
*/
|
|
||||||
public function disconnect(User $user): void
|
public function disconnect(User $user): void
|
||||||
{
|
{
|
||||||
$user->keka_ms_access_token = null;
|
OauthToken::where('user_id', $user->id)
|
||||||
$user->keka_ms_refresh_token = null;
|
->where('provider', self::PROVIDER)
|
||||||
$user->keka_ms_token_expires_at = null;
|
->delete();
|
||||||
$user->keka_ms_email = null;
|
}
|
||||||
$user->save();
|
|
||||||
|
private function storeTokens(User $user, array $data): void
|
||||||
|
{
|
||||||
|
OauthToken::updateOrCreate(
|
||||||
|
['user_id' => $user->id, 'provider' => self::PROVIDER],
|
||||||
|
[
|
||||||
|
'access_token' => $data['access_token'],
|
||||||
|
'refresh_token' => $data['refresh_token'] ?? null,
|
||||||
|
'token_type' => $data['token_type'] ?? 'Bearer',
|
||||||
|
'scopes' => $data['scope'] ?? implode(' ', $this->scopes),
|
||||||
|
'expires_at' => now()->addSeconds((int) ($data['expires_in'] ?? 3600)),
|
||||||
|
]
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -7,22 +7,13 @@
|
|||||||
'client_secret' => env('MICROSOFT_GRAPH_CLIENT_SECRET'),
|
'client_secret' => env('MICROSOFT_GRAPH_CLIENT_SECRET'),
|
||||||
'tenant_id' => env('MICROSOFT_GRAPH_TENANT_ID'),
|
'tenant_id' => env('MICROSOFT_GRAPH_TENANT_ID'),
|
||||||
|
|
||||||
// Your app's redirect URI for Keka OAuth flow
|
'keka_redirect_uri' => env('KEKA_MS_REDIRECT_URI', 'http://localhost:8000/keka/callback'),
|
||||||
'keka_redirect_uri' => env('KEKA_MS_REDIRECT_URI', 'http://localhost:8000/keka/callback'),
|
|
||||||
|
|
||||||
// After first-time login: Keka's own Microsoft SSO URL
|
|
||||||
// After already connected: Keka dashboard
|
|
||||||
'keka_login_url' => env('KEKA_LOGIN_URL', 'https://sentientgeeks.keka.com/'),
|
|
||||||
'keka_dashboard_url' => env('KEKA_DASHBOARD_URL', 'https://sentientgeeks.keka.com/'),
|
'keka_dashboard_url' => env('KEKA_DASHBOARD_URL', 'https://sentientgeeks.keka.com/'),
|
||||||
|
|
||||||
'keka_scopes' => [
|
'keka_scopes' => [
|
||||||
'openid',
|
'openid', 'profile', 'email',
|
||||||
'profile',
|
'offline_access', // ← required for refresh_token
|
||||||
'email',
|
|
||||||
'offline_access',
|
|
||||||
'User.Read',
|
'User.Read',
|
||||||
'Mail.Read',
|
'Mail.Read', 'Mail.ReadWrite', 'Mail.Send',
|
||||||
'Mail.ReadWrite',
|
|
||||||
'Mail.Send',
|
|
||||||
],
|
],
|
||||||
];
|
];
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user