diff --git a/Modules/Helpdesk/Http/Controllers/NativeAuthorizationController.php b/Modules/Helpdesk/Http/Controllers/NativeAuthorizationController.php index 759fa2d..ef29d30 100644 --- a/Modules/Helpdesk/Http/Controllers/NativeAuthorizationController.php +++ b/Modules/Helpdesk/Http/Controllers/NativeAuthorizationController.php @@ -4,30 +4,81 @@ declare(strict_types=1); namespace Modules\Helpdesk\Http\Controllers; +use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; use Illuminate\Routing\Controller; +use Illuminate\Support\Facades\DB; +use Illuminate\Support\Str; use Illuminate\View\View; final class NativeAuthorizationController extends Controller { public function authorize(Request $request): View { - $request->validate([ - 'client_id' => ['required', 'string'], - 'redirect_uri' => ['required', 'string'], + $data = $this->validateAuthorizationRequest($request); + + return view('helpdesk::native.authorize', [ + 'clientId' => $data['client_id'], + 'parameters' => $data, + ]); + } + + public function approve(Request $request): RedirectResponse + { + $data = $this->validateAuthorizationRequest($request); + + $code = Str::random(64); + + DB::table('helpdesk_native_authorization_codes')->insert([ + 'user_id' => $request->user()->getKey(), + 'client_id' => $data['client_id'], + 'code_hash' => hash('sha256', $code), + 'redirect_uri' => $data['redirect_uri'], + 'code_challenge' => $data['code_challenge'], + 'code_challenge_method' => 'S256', + 'expires_at' => now()->addSeconds( + (int) config('helpdesk-native.authorization_code_lifetime', 60) + ), + 'created_at' => now(), + 'updated_at' => now(), + ]); + + $separator = str_contains($data['redirect_uri'], '?') ? '&' : '?'; + + return redirect()->away( + $data['redirect_uri'].$separator.http_build_query([ + 'code' => $code, + 'state' => $data['state'], + ]) + ); + } + + private function validateAuthorizationRequest(Request $request): array + { + $data = $request->validate([ + 'client_id' => ['required', 'string', 'max:100'], + 'redirect_uri' => ['required', 'string', 'max:2048'], 'response_type' => ['required', 'in:code'], - 'state' => ['required', 'string', 'min:32'], - 'code_challenge' => ['required', 'string', 'size:43'], + 'state' => ['required', 'string', 'min:32', 'max:512'], + 'code_challenge' => [ + 'required', + 'string', + 'regex:/^[A-Za-z0-9_-]{43}$/', + ], 'code_challenge_method' => ['required', 'in:S256'], ]); - // Authorization-code issuance is not implemented yet. - // Never redirect a code or token to an unregistered URI. - abort(501, 'Native authorization is not yet implemented.'); - } + $registeredUris = config( + 'helpdesk-native.clients.'.$data['client_id'].'.redirect_uris', + [] + ); - public function approve(Request $request): void - { - abort(501, 'Native authorization approval is not yet implemented.'); + abort_unless( + in_array($data['redirect_uri'], $registeredUris, true), + 400, + 'Unregistered native application callback.' + ); + + return $data; } } diff --git a/Modules/Helpdesk/Http/Controllers/NativeTokenController.php b/Modules/Helpdesk/Http/Controllers/NativeTokenController.php new file mode 100644 index 0000000..58edfcd --- /dev/null +++ b/Modules/Helpdesk/Http/Controllers/NativeTokenController.php @@ -0,0 +1,103 @@ +validate([ + 'grant_type' => ['required', 'in:authorization_code'], + 'client_id' => ['required', 'string'], + 'code' => ['required', 'string'], + 'redirect_uri' => ['required', 'string'], + 'code_verifier' => [ + 'required', + 'string', + 'regex:/^[A-Za-z0-9._~-]{43,128}$/', + ], + ]); + + $challenge = rtrim( + strtr( + base64_encode(hash('sha256', $data['code_verifier'], true)), + '+/', + '-_' + ), + '=' + ); + + $result = DB::transaction(function () use ($data, $challenge): ?array { + $authorization = DB::table('helpdesk_native_authorization_codes') + ->where('code_hash', hash('sha256', $data['code'])) + ->lockForUpdate() + ->first(); + + if (! $authorization) { + return null; + } + + if ( + $authorization->consumed_at !== null + || now()->greaterThanOrEqualTo($authorization->expires_at) + || ! hash_equals($authorization->client_id, $data['client_id']) + || ! hash_equals($authorization->redirect_uri, $data['redirect_uri']) + || $authorization->code_challenge_method !== 'S256' + || ! hash_equals($authorization->code_challenge, $challenge) + ) { + return null; + } + + $user = User::query()->find($authorization->user_id); + + if (! $user) { + return null; + } + + DB::table('helpdesk_native_authorization_codes') + ->where('id', $authorization->id) + ->update([ + 'consumed_at' => now(), + 'updated_at' => now(), + ]); + + $token = $user->createToken( + 'HelpdeskMac', + ['helpdesk:native'], + now()->addDays( + (int) config('helpdesk-native.access_token_lifetime_days', 30) + ) + ); + + return [ + 'access_token' => $token->plainTextToken, + 'token_type' => 'Bearer', + 'expires_at' => $token->accessToken->expires_at?->toIso8601String(), + 'user' => [ + 'id' => $user->getKey(), + 'name' => $user->name, + 'email' => $user->email, + ], + ]; + }); + + if ($result === null) { + throw ValidationException::withMessages([ + 'code' => ['Invalid or expired authorization code.'], + ]); + } + + return response()->json($result) + ->header('Cache-Control', 'no-store') + ->header('Pragma', 'no-cache'); + } +} diff --git a/Modules/Helpdesk/Resources/views/native/authorize.blade.php b/Modules/Helpdesk/Resources/views/native/authorize.blade.php new file mode 100644 index 0000000..dde3f50 --- /dev/null +++ b/Modules/Helpdesk/Resources/views/native/authorize.blade.php @@ -0,0 +1,41 @@ + + + + + + Authorize HelpdeskMac + + +
+

Authorize HelpdeskMac

+ +

+ HelpdeskMac is requesting access to your Helpdesk account. +

+ +

+ Signed in as {{ auth()->user()->email }} +

+ +
+ @csrf + + @foreach ($parameters as $name => $value) + + @endforeach + + +
+ +

+ Only approve this request if you initiated login from HelpdeskMac. +

+
+ + diff --git a/Modules/Helpdesk/Routes/api.php b/Modules/Helpdesk/Routes/api.php index c08be01..672fa09 100755 --- a/Modules/Helpdesk/Routes/api.php +++ b/Modules/Helpdesk/Routes/api.php @@ -8,6 +8,7 @@ use Modules\Helpdesk\Http\Controllers\HelpdeskSyncController; use Modules\Helpdesk\Http\Controllers\NativeTicketController; use Modules\Helpdesk\Http\Middleware\VerifyHelpdeskSyncToken; use Modules\Helpdesk\Http\Controllers\NativeAuthController; +use Modules\Helpdesk\Http\Controllers\NativeTokenController; Route::get('/status', static fn (): array => [ 'module' => 'helpdesk', @@ -57,4 +58,8 @@ Route::prefix('native/v1') Route::post('tickets', [NativeTicketController::class, 'store']) ->name('tickets.store'); }); + Route::post('auth/token', [NativeTokenController::class, 'exchange']) + ->middleware('throttle:10,1') + ->name('auth.token'); }); + diff --git a/config/helpdesk-native.php b/config/helpdesk-native.php new file mode 100644 index 0000000..f281362 --- /dev/null +++ b/config/helpdesk-native.php @@ -0,0 +1,17 @@ + [ + 'helpdesk-macos' => [ + 'redirect_uris' => [ + 'helpdeskmac://auth/callback', + ], + ], + ], + + 'authorization_code_lifetime' => 60, + + 'access_token_lifetime_days' => 30, +]; diff --git a/database/migrations/2026_10_14_152226_create_helpdesk_native_authorization_codes_table.php b/database/migrations/2026_10_14_152226_create_helpdesk_native_authorization_codes_table.php new file mode 100644 index 0000000..0491f5f --- /dev/null +++ b/database/migrations/2026_10_14_152226_create_helpdesk_native_authorization_codes_table.php @@ -0,0 +1,50 @@ +id(); + + $table->foreignId('user_id') + ->constrained('users') + ->cascadeOnDelete(); + + $table->string('client_id', 100); + + // Store only the SHA-256 hash of the authorization code. + $table->char('code_hash', 64)->unique(); + + // Exact registered callback URI for this authorization attempt. + $table->string('redirect_uri', 2048); + + // PKCE S256 challenge (base64url encoded). + $table->string('code_challenge', 128); + + $table->string('code_challenge_method', 10) + ->default('S256'); + + $table->timestamp('expires_at'); + + // Set atomically when the code is redeemed. + $table->timestamp('consumed_at')->nullable(); + + $table->timestamps(); + + $table->index(['client_id', 'expires_at']); + $table->index(['user_id', 'created_at']); + }); + } + + public function down(): void + { + Schema::dropIfExists('helpdesk_native_authorization_codes'); + } +};