Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
97.30% covered (success)
97.30%
108 / 111
66.67% covered (warning)
66.67%
2 / 3
CRAP
0.00% covered (danger)
0.00%
0 / 1
MasqueradeService
97.30% covered (success)
97.30%
108 / 111
66.67% covered (warning)
66.67%
2 / 3
9
0.00% covered (danger)
0.00%
0 / 1
 generate
100.00% covered (success)
100.00%
42 / 42
100.00% covered (success)
100.00%
1 / 1
5
 generateCompanyToken
100.00% covered (success)
100.00%
28 / 28
100.00% covered (success)
100.00%
1 / 1
1
 start
92.68% covered (success)
92.68%
38 / 41
0.00% covered (danger)
0.00%
0 / 1
3.00
1<?php
2
3namespace App\Http\Services\Admin;
4
5use App\Http\Models\Admin\Company;
6use App\Http\Models\Auth\User;
7use App\Http\Models\MasqueradeLog;
8use Carbon\Carbon;
9use Firebase\JWT\JWT;
10use Firebase\JWT\Key;
11use Illuminate\Http\Request;
12
13/**
14 * MasqueradeService
15 *
16 * Handles admin masquerade (impersonation) operations using JWT tokens.
17 * The generate() method creates a JWT bundling the masqueraded user's Passport
18 * access token and admin session backup. The start() method decodes the JWT
19 * and returns session data for the frontend to switch contexts.
20 */
21class MasqueradeService
22{
23    /**
24     * Generate a masquerade JWT token for impersonating a target user.
25     *
26     * Creates a Passport token for the target user, bundles it with the admin's
27     * session data into a signed JWT, and returns the token with a masquerade URL.
28     *
29     * @param  User  $admin  The admin user performing the masquerade
30     * @param  array  $data  Validated request data containing user_id, optional company_id, and optional admin_refresh_token
31     * @param  Request  $request  The HTTP request (for audit logging)
32     * @return array{masquerade_token: string, masquerade_url: string, expires_at: int}
33     *
34     * @throws \Illuminate\Database\Eloquent\ModelNotFoundException When target user or company not found
35     */
36    public function generate(User $admin, array $data, Request $request): array
37    {
38        $companyId = $data['company_id'] ?? null;
39
40        $query = User::where('_id', $data['user_id']);
41        if ($companyId) {
42            $query->where('company_id', $companyId);
43        }
44        $targetUser = $query->firstOrFail();
45
46        $company = $companyId ? Company::findOrFail($companyId) : $targetUser->company;
47
48        $expiryHours = config('services.masquerade.token_expiry_hours', 2);
49        $expiresAt = Carbon::now()->addHours($expiryHours);
50
51        $tokenResult = $targetUser->createToken('MasqueradeToken');
52        $tokenResult->token->expires_at = $expiresAt;
53        $tokenResult->token->save();
54
55        $secret = config('services.masquerade.jwt_secret');
56
57        $payload = [
58            'masquerade_access_token' => $tokenResult->accessToken,
59            'masquerade_user_id' => (string) $targetUser->_id,
60            'masquerade_company_id' => $company ? (string) $company->_id : null,
61            'masquerade_company_slug' => $company?->slug,
62            'masquerade_company_name' => $company?->name,
63            'admin_access_token' => $request->bearerToken(),
64            'admin_refresh_token' => $data['admin_refresh_token'] ?? null,
65            'admin_user_id' => (string) $admin->_id,
66            'iat' => now()->timestamp,
67            'exp' => $expiresAt->timestamp,
68        ];
69
70        $jwt = JWT::encode($payload, $secret, 'HS256');
71
72        $masqueradeUrl = rtrim(config('romeo.frontend-base-url'), '/').'/masquerade?token='.$jwt;
73
74        MasqueradeLog::create([
75            'admin_id' => (string) $admin->_id,
76            'admin_email' => $admin->email,
77            'target_user_id' => (string) $targetUser->_id,
78            'target_user_email' => $targetUser->email,
79            'company_id' => $company ? (string) $company->_id : null,
80            'action' => 'generate',
81            'ip_address' => $request->ip(),
82            'user_agent' => $request->userAgent(),
83            'expires_at' => $expiresAt,
84        ]);
85
86        return [
87            'masquerade_token' => $jwt,
88            'masquerade_url' => $masqueradeUrl,
89            'expires_at' => $expiresAt->timestamp,
90        ];
91    }
92
93    /**
94     * Generate a masquerade JWT token for a company context.
95     *
96     * Creates a signed JWT containing company metadata so the admin portal
97     * can switch the company context. No Passport access token swap is needed.
98     *
99     * @param  User  $admin  The admin user performing the masquerade
100     * @param  Company  $company  The target company to masquerade into
101     * @param  Request  $request  The HTTP request (for audit logging)
102     * @return array{masquerade_url: string, expires_at: int}
103     */
104    public function generateCompanyToken(User $admin, Company $company, Request $request): array
105    {
106        $expiryHours = config('services.masquerade.token_expiry_hours', 2);
107        $expiresAt = Carbon::now()->addHours($expiryHours);
108
109        $secret = config('services.masquerade.jwt_secret');
110
111        $payload = [
112            'masquerade_company_id' => (string) $company->_id,
113            'masquerade_company_slug' => $company->slug,
114            'masquerade_company_name' => $company->name,
115            'admin_user_id' => (string) $admin->_id,
116            'iat' => now()->timestamp,
117            'exp' => $expiresAt->timestamp,
118        ];
119
120        $jwt = JWT::encode($payload, $secret, 'HS256');
121
122        $masqueradeUrl = rtrim(config('romeo.admin-app-url'), '/').'/masquerade/company?token='.$jwt;
123
124        MasqueradeLog::create([
125            'admin_id' => (string) $admin->_id,
126            'admin_email' => $admin->email,
127            'target_user_id' => null,
128            'target_user_email' => null,
129            'company_id' => (string) $company->_id,
130            'action' => 'generate_company',
131            'ip_address' => $request->ip(),
132            'user_agent' => $request->userAgent(),
133            'expires_at' => $expiresAt,
134        ]);
135
136        return [
137            'masquerade_url' => $masqueradeUrl,
138            'expires_at' => $expiresAt->timestamp,
139        ];
140    }
141
142    /**
143     * Start a masquerade session by decoding a JWT token.
144     *
145     * Validates and decodes the masquerade JWT, looks up the target user and admin user, and returns session data for the frontend to switch contexts.
146     *
147     * @param  string  $token  The masquerade JWT token
148     * @param  Request  $request  The HTTP request (for audit logging)
149     * @return array{user: array, admin_backup: array, expires_at: int}
150     *
151     * @throws \Firebase\JWT\ExpiredException When JWT has expired
152     * @throws \Firebase\JWT\SignatureInvalidException When JWT signature is invalid
153     * @throws \UnexpectedValueException When JWT is malformed
154     */
155    public function start(string $token, Request $request): array
156    {
157        $parts = explode('.', $token);
158        if (count($parts) !== 3) {
159            throw new \UnexpectedValueException('Invalid masquerade token format');
160        }
161
162        $header = json_decode(base64_decode(strtr($parts[0], '-_', '+/')), true);
163        if (($header['alg'] ?? null) !== 'HS256') {
164            throw new \UnexpectedValueException(
165                'Invalid token: expected masquerade token (HS256), got '.($header['alg'] ?? 'unknown').'. Ensure you are passing the masquerade_token from the generate endpoint, not a Passport access token.'
166            );
167        }
168
169        $secret = config('services.masquerade.jwt_secret');
170        $decoded = JWT::decode($token, new Key($secret, 'HS256'));
171
172        $targetUser = User::with('company')->findOrFail($decoded->masquerade_user_id);
173        $adminUser = User::findOrFail($decoded->admin_user_id);
174
175        $targetUser['is_poc'] = $targetUser->isPOC();
176
177        MasqueradeLog::create([
178            'admin_id' => $decoded->admin_user_id,
179            'admin_email' => $adminUser->email,
180            'target_user_id' => $decoded->masquerade_user_id,
181            'target_user_email' => $targetUser->email,
182            'company_id' => $decoded->masquerade_company_id,
183            'action' => 'start',
184            'ip_address' => $request->ip(),
185            'user_agent' => $request->userAgent(),
186            'expires_at' => Carbon::createFromTimestamp($decoded->exp),
187        ]);
188
189        return [
190            'user' => [
191                'token_type' => 'Bearer',
192                'access_token' => $decoded->masquerade_access_token,
193                'expires_in' => $decoded->exp - now()->timestamp,
194                'session_expires_in' => intval(config('auth.passport.refresh_token_expiry')),
195                'user_details' => $targetUser,
196                'company' => $decoded->masquerade_company_slug,
197            ],
198            'admin_backup' => [
199                'access_token' => $decoded->admin_access_token,
200                'refresh_token' => $decoded->admin_refresh_token ?? null,
201                'user_id' => $decoded->admin_user_id,
202                'email' => $adminUser->email,
203            ],
204            'expires_at' => $decoded->exp,
205        ];
206    }
207}