diff --git a/.dockerignore b/.dockerignore index 51dbceeac..99086b40d 100644 --- a/.dockerignore +++ b/.dockerignore @@ -16,7 +16,6 @@ tests .phpunit.result.cache .styleci.yml phpunit.xml -docs ssl db redis diff --git a/app/Console/Kernel.php b/app/Console/Kernel.php index 92dd847ee..eeec80755 100644 --- a/app/Console/Kernel.php +++ b/app/Console/Kernel.php @@ -53,6 +53,7 @@ protected function schedule(Schedule $schedule): void $schedule->command('telescope:prune')->daily()->onOneServer(); $schedule->command('horizon:snapshot')->everyFiveMinutes()->onOneServer(); $schedule->command(ImportRecordingsCommand::class)->everyMinute()->withoutOverlapping()->onOneServer(); + $schedule->command('passport:purge')->hourly()->onOneServer(); } /** diff --git a/app/Exceptions/Handler.php b/app/Exceptions/Handler.php index a57133302..9dac70b1c 100644 --- a/app/Exceptions/Handler.php +++ b/app/Exceptions/Handler.php @@ -10,8 +10,10 @@ use Illuminate\Http\Request; use Illuminate\Support\Facades\Log; use Illuminate\Support\Str; +use Laravel\Passport\Exceptions\MissingScopeException; use Psr\Log\LogLevel; use Spatie\LaravelIgnition\Exceptions\ViewException; +use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; use Throwable; @@ -97,4 +99,16 @@ public function register(): void } }); } + + public function render($request, Throwable $e): Response + { + // Overwrite default rendering of the MissingScopeException + if ($e instanceof MissingScopeException) { + return response()->json([ + 'message' => 'Missing required scope(s): '.implode(', ', $e->scopes()), + ], 403); + } + + return parent::render($request, $e); + } } diff --git a/app/Http/Controllers/api/v1/OAuthAuthorizationController.php b/app/Http/Controllers/api/v1/OAuthAuthorizationController.php new file mode 100644 index 000000000..aab42234e --- /dev/null +++ b/app/Http/Controllers/api/v1/OAuthAuthorizationController.php @@ -0,0 +1,22 @@ +guard = auth()->guard(); + + return parent::authorize($psrRequest, $request, $psrResponse, $viewResponse); + } +} diff --git a/app/Http/Controllers/api/v1/OAuthTokenController.php b/app/Http/Controllers/api/v1/OAuthTokenController.php new file mode 100644 index 000000000..b8aadf7f9 --- /dev/null +++ b/app/Http/Controllers/api/v1/OAuthTokenController.php @@ -0,0 +1,34 @@ +oauthTokens() + ->where('revoked', false) + ->orderByDesc('created_at') + ->get(); + + return OAuthTokenResource::collection($tokens); + } + + public function destroy(string $token) + { + $oauthToken = Auth::user()->oauthTokens()->findOrFail($token); + + $oauthToken->revoke(); + + $oauthToken->refreshToken?->revoke(); + + return response()->noContent(); + } +} diff --git a/app/Http/Controllers/external/v1/CurrentTokenController.php b/app/Http/Controllers/external/v1/CurrentTokenController.php new file mode 100644 index 000000000..12641facd --- /dev/null +++ b/app/Http/Controllers/external/v1/CurrentTokenController.php @@ -0,0 +1,16 @@ +user()); + } +} diff --git a/app/Http/Controllers/external/v1/RoomController.php b/app/Http/Controllers/external/v1/RoomController.php new file mode 100644 index 000000000..b7cf99151 --- /dev/null +++ b/app/Http/Controllers/external/v1/RoomController.php @@ -0,0 +1,51 @@ +authorizeResource(Room::class, 'room'); + } + + public function index(): AnonymousResourceCollection + { + $rooms = Room::query() + ->where('user_id', Auth::id()) + ->with('roomType') + ->orderByRaw('LOWER(name)') + ->orderBy('id') + ->get(); + + return RoomResource::collection($rooms); + } + + public function store(CreateRoomRequest $request): RoomResource + { + if (Auth::user()->hasRoomLimitExceeded()) { + abort(429, __('app.errors.room_limit_exceeded')); + } + + $room = new Room; + $room->name = $request->validated('name'); + $room->access_code = $request->validated('access_code'); + $room->allow_guests = (bool) $request->validated('allow_guests'); + $room->roomType()->associate($request->validated('room_type')); + $room->owner()->associate(Auth::user()); + $room->save(); + + $room->save(); + + return new RoomResource($room); + } +} diff --git a/app/Http/Controllers/external/v1/RoomTypeController.php b/app/Http/Controllers/external/v1/RoomTypeController.php new file mode 100644 index 000000000..c3fe6d00a --- /dev/null +++ b/app/Http/Controllers/external/v1/RoomTypeController.php @@ -0,0 +1,31 @@ +query('filter') === 'own') { + // Get list of the room type the current user has access to (Used when creating a new room) + $roomTypes = $roomTypes->where('restrict', '=', false) + ->orWhereIn('id', function ($query) { + $query->select('role_room_type.room_type_id') + ->from('role_room_type as role_room_type') + ->whereIn('role_room_type.role_id', Auth::user()->roles->pluck('id')->all()); + }); + } + + return RoomTypeResource::collection($roomTypes->get()); + } +} diff --git a/app/Http/Kernel.php b/app/Http/Kernel.php index 93166b33b..8e1b89608 100644 --- a/app/Http/Kernel.php +++ b/app/Http/Kernel.php @@ -31,12 +31,14 @@ use Illuminate\Foundation\Http\Middleware\ConvertEmptyStringsToNull; use Illuminate\Foundation\Http\Middleware\InvokeDeferredCallbacks; use Illuminate\Foundation\Http\Middleware\ValidatePostSize; +use Illuminate\Http\Middleware\HandleCors; use Illuminate\Http\Middleware\SetCacheHeaders; use Illuminate\Routing\Middleware\SubstituteBindings; use Illuminate\Routing\Middleware\ThrottleRequests; use Illuminate\Routing\Middleware\ValidateSignature; use Illuminate\Session\Middleware\StartSession; use Illuminate\View\Middleware\ShareErrorsFromSession; +use Laravel\Passport\Http\Middleware\CheckToken; use Laravel\Sanctum\Http\Middleware\EnsureFrontendRequestsAreStateful; class Kernel extends HttpKernel @@ -52,6 +54,7 @@ class Kernel extends HttpKernel InvokeDeferredCallbacks::class, TrustHosts::class, TrustProxies::class, + HandleCors::class, PreventRequestsDuringMaintenance::class, ValidatePostSize::class, TrimStrings::class, @@ -89,6 +92,13 @@ class Kernel extends HttpKernel SetApplicationLocale::class, LogContext::class, ], + + 'external_api' => [ + ThrottleRequests::class.':external_api', + SubstituteBindings::class, + SetApplicationLocale::class, + LogContext::class, + ], ]; /** @@ -113,5 +123,6 @@ class Kernel extends HttpKernel 'check.stale' => EnsureModelNotStale::class, 'enable_if_config' => RouteEnableIfConfig::class, 'shibboleth' => ShibbolethSessionMiddleware::class, + 'scope' => CheckToken::class, ]; } diff --git a/app/Http/Middleware/ApiRedirectMiddleware.php b/app/Http/Middleware/ApiRedirectMiddleware.php new file mode 100644 index 000000000..ea87b032e --- /dev/null +++ b/app/Http/Middleware/ApiRedirectMiddleware.php @@ -0,0 +1,24 @@ +isRedirection()) { + return response()->json([ + 'redirect' => $response->headers->get('Location'), + ]); + } + + return $response; + } +} diff --git a/app/Http/Middleware/PreventRequestForgery.php b/app/Http/Middleware/PreventRequestForgery.php index 5cf701942..5e883bea0 100644 --- a/app/Http/Middleware/PreventRequestForgery.php +++ b/app/Http/Middleware/PreventRequestForgery.php @@ -16,5 +16,6 @@ class PreventRequestForgery extends Middleware protected $except = [ 'auth/shibboleth/logout', 'auth/oidc/logout', + 'oauth/token', ]; } diff --git a/app/Http/Requests/External/CreateRoomRequest.php b/app/Http/Requests/External/CreateRoomRequest.php new file mode 100644 index 000000000..7865900da --- /dev/null +++ b/app/Http/Requests/External/CreateRoomRequest.php @@ -0,0 +1,71 @@ +|string> + */ + public function rules(): array + { + $rules = [ + 'name' => ['required', 'string', 'min:2', 'max:'.config('bigbluebutton.room_name_limit')], + 'room_type' => ['bail', 'required', 'integer', 'exists:App\Models\RoomType,id', new ValidRoomType(Auth::user())], + 'access_code' => $this->getAccessCodeValidationRule(), + 'allow_guests' => $this->getRoomSettingValidationRule('allow_guests'), + ]; + + return $rules; + } + + private function getAccessCodeValidationRule(): array + { + $rules = ['string', 'numeric', 'digits:9', 'bail']; + + // Make sure that the given room type id is a number + if (is_numeric($this->input('room_type'))) { + // Check if a room type exists with the given number + $newRoomType = RoomType::find($this->input('room_type')); + if ($newRoomType) { + // Set access code to required if enforced in room type + if ($newRoomType->has_access_code_enforced && $newRoomType->has_access_code_default) { + array_unshift($rules, 'required'); + } + // Set access code to prohibited if enforced in room type + elseif ($newRoomType->has_access_code_enforced && ! $newRoomType->has_access_code_default) { + array_unshift($rules, 'prohibited', 'nullable'); + } + // Set access code to nullable (room can have an access code but access code is not enforced) + else { + array_unshift($rules, 'nullable'); + } + } + } + + return $rules; + } + + private function getRoomSettingValidationRule(string $settingName): array + { + if (is_numeric($this->input('room_type'))) { + $newRoomType = RoomType::find($this->input('room_type')); + if ($newRoomType) { + return Room::getRoomSettingValidationRule($settingName, $newRoomType); + } + } + + return Room::getRoomSettingValidationRule($settingName); + } +} diff --git a/app/Http/Requests/External/RoomTypeIndexRequest.php b/app/Http/Requests/External/RoomTypeIndexRequest.php new file mode 100644 index 000000000..a02015950 --- /dev/null +++ b/app/Http/Requests/External/RoomTypeIndexRequest.php @@ -0,0 +1,23 @@ +|string> + */ + public function rules(): array + { + return [ + 'filter' => ['nullable', 'string', 'in:own'], + ]; + } +} diff --git a/app/Http/Requests/UpdateRoomSettingsRequest.php b/app/Http/Requests/UpdateRoomSettingsRequest.php index 8d50976d0..fd59959d7 100644 --- a/app/Http/Requests/UpdateRoomSettingsRequest.php +++ b/app/Http/Requests/UpdateRoomSettingsRequest.php @@ -51,7 +51,7 @@ private function getAccessCodeValidationRule(): array $rules = $legacy ? ['alpha_num:ascii', 'lowercase', 'size:6', 'bail'] - : ['numeric', 'digits:9', 'bail']; + : ['string', 'numeric', 'digits:9', 'bail']; // Make sure that the given room type id is a number if (is_numeric($this->input('room_type'))) { diff --git a/app/Http/Resources/ConfigResource.php b/app/Http/Resources/ConfigResource.php index dd529a9c1..516869384 100644 --- a/app/Http/Resources/ConfigResource.php +++ b/app/Http/Resources/ConfigResource.php @@ -112,6 +112,7 @@ public function toArray($request) 'ldap' => config('ldap.enabled'), 'shibboleth' => config('services.shibboleth.enabled'), 'oidc' => config('services.oidc.enabled'), + 'oauth' => config('passport.enabled'), ], ]; } diff --git a/app/Http/Resources/External/CurrentUserResource.php b/app/Http/Resources/External/CurrentUserResource.php new file mode 100644 index 000000000..7ed34e605 --- /dev/null +++ b/app/Http/Resources/External/CurrentUserResource.php @@ -0,0 +1,32 @@ + $this->id, + 'image' => $this->imageUrl, + 'email' => $this->email, + 'firstname' => $this->firstname, + 'lastname' => $this->lastname, + 'locale' => $this->locale, + 'room_limit' => $this->room_limit, + 'timezone' => $this->timezone, + 'permissions' => $this->permissions, + ]; + } +} diff --git a/app/Http/Resources/External/RoomResource.php b/app/Http/Resources/External/RoomResource.php new file mode 100644 index 000000000..50c9ff2c0 --- /dev/null +++ b/app/Http/Resources/External/RoomResource.php @@ -0,0 +1,23 @@ + $this->id, + 'name' => $this->name, + 'room_type' => new RoomTypeResource($this->roomType), + 'access_code' => $this->access_code, + 'allow_guests' => (bool) $this->allow_guests, + 'link' => rtrim(config('app.url'), '/').'/rooms/'.$this->id, + ]; + } +} diff --git a/app/Http/Resources/External/RoomTypeResource.php b/app/Http/Resources/External/RoomTypeResource.php new file mode 100644 index 000000000..b94a49d3c --- /dev/null +++ b/app/Http/Resources/External/RoomTypeResource.php @@ -0,0 +1,41 @@ +has_access_code_default; + $settings['has_access_code_enforced'] = $this->has_access_code_enforced; + + $settings['has_allow_guests_default'] = $this->has_allow_guests_default; + $settings['has_allow_guests_enforced'] = $this->has_allow_guests_enforced; + + return $settings; + } + + /** + * Transform the resource into an array. + * + * @param Request $request + * @return array + */ + public function toArray($request) + { + return [ + 'id' => $this->id, + 'name' => $this->name, + 'description' => $this->description, + + 'room_settings' => $this->getDefaultRoomSettings(), + ]; + } +} diff --git a/app/Http/Resources/External/UserResource.php b/app/Http/Resources/External/UserResource.php new file mode 100644 index 000000000..1cc36c47c --- /dev/null +++ b/app/Http/Resources/External/UserResource.php @@ -0,0 +1,31 @@ + $this->id, + 'image' => $this->imageUrl, + 'email' => $this->email, + 'firstname' => $this->firstname, + 'lastname' => $this->lastname, + 'locale' => $this->locale, + 'room_limit' => $this->room_limit, + 'timezone' => $this->timezone, + ]; + } +} diff --git a/app/Http/Resources/OAuthTokenResource.php b/app/Http/Resources/OAuthTokenResource.php new file mode 100644 index 000000000..e9ef1c62b --- /dev/null +++ b/app/Http/Resources/OAuthTokenResource.php @@ -0,0 +1,23 @@ + $this->id, + 'name' => $this->client->name, + 'scopes' => $this->scopes, + 'last_used_at' => $this->last_used_at, + 'created_at' => $this->created_at, + 'expires_at' => $this->expires_at, + ]; + } +} diff --git a/app/Models/Room.php b/app/Models/Room.php index 4c2bf03f3..2ad6dd68b 100644 --- a/app/Models/Room.php +++ b/app/Models/Room.php @@ -128,11 +128,12 @@ protected function casts() * Setting must be defined in the ROOM_SETTINGS_DEFINITION * * @param string $settingName setting name of the setting + * @param RoomType|null $roomType (Optional) Room type to apply restrictions * @return string[] setting rules for the setting * * @throws \Exception */ - public static function getRoomSettingValidationRule($settingName) + public static function getRoomSettingValidationRule(string $settingName, ?RoomType $roomType = null): array { $rules = ['required']; @@ -145,16 +146,26 @@ public static function getRoomSettingValidationRule($settingName) // Boolean if ($castType === 'boolean') { - array_push($rules, 'boolean'); + $rules[] = 'boolean'; + + if ($roomType && $roomType[$settingName.'_enforced']) { + $rules[] = $roomType[$settingName.'_default'] ? 'accepted' : 'declined'; + } } // Enum elseif (enum_exists($castType)) { $enumValidation = Rule::enum($castType); - // Only some values allowed - if (isset(self::ROOM_SETTINGS_DEFINITION[$settingName]['only'])) { - $enumValidation = $enumValidation->only(self::ROOM_SETTINGS_DEFINITION[$settingName]['only']); + + if ($roomType && $roomType[$settingName.'_enforced']) { + $enumValidation = $enumValidation->only($roomType[$settingName.'_default']); + } else { + // Only some values allowed + if (isset(self::ROOM_SETTINGS_DEFINITION[$settingName]['only'])) { + $enumValidation = $enumValidation->only(self::ROOM_SETTINGS_DEFINITION[$settingName]['only']); + } } + array_push($rules, $enumValidation); } // Room setting validation with invalid cast diff --git a/app/Models/User.php b/app/Models/User.php index 7940c509e..6d6d062fd 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -20,13 +20,24 @@ use Illuminate\Notifications\Notification; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Storage; -use Laravel\Sanctum\HasApiTokens; +use Laravel\Passport\Contracts\OAuthenticatable; +use Laravel\Passport\HasApiTokens; +use Laravel\Passport\Token; #[ObservedBy([UserObserver::class])] -class User extends Authenticatable implements HasLocalePreference +class User extends Authenticatable implements HasLocalePreference, OAuthenticatable { use AddsModelNameTrait, HasApiTokens, HasFactory, Notifiable; + public function getProviderName(): string + { + if ($this->authenticator === 'ldap') { + return 'ldap'; + } + + return 'users'; + } + /** * The attributes that are mass assignable. * @@ -298,6 +309,11 @@ public function sessions() return $this->hasMany(Session::class); } + public function oauthTokens() + { + return $this->hasMany(Token::class, 'user_id'); + } + public function verifyEmails() { return $this->hasMany(VerifyEmail::class); diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 734ccae29..5ebd36c4c 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -11,6 +11,7 @@ use Illuminate\Pagination\Paginator; use Illuminate\Support\Facades\Schema; use Illuminate\Support\ServiceProvider; +use Laravel\Passport\Passport; use Laravel\Pulse\Contracts\ResolvesUsers; class AppServiceProvider extends ServiceProvider @@ -36,5 +37,7 @@ public function register(): void $this->app->register(TelescopeServiceProvider::class); $this->app->singleton(StreamingServiceFactory::class, StreamingServiceFactory::class); + + Passport::ignoreRoutes(); } } diff --git a/app/Providers/AuthServiceProvider.php b/app/Providers/AuthServiceProvider.php index 07cd533a3..65b4a9a2b 100644 --- a/app/Providers/AuthServiceProvider.php +++ b/app/Providers/AuthServiceProvider.php @@ -16,8 +16,11 @@ use App\Policies\RoomTypePolicy; use App\Policies\ServerPolicy; use App\Policies\UserPolicy; +use Carbon\CarbonInterval; +use ErrorException; use Illuminate\Foundation\Support\Providers\AuthServiceProvider as ServiceProvider; use Illuminate\Support\Facades\Gate; +use Laravel\Passport\Passport; class AuthServiceProvider extends ServiceProvider { @@ -58,5 +61,74 @@ public function boot(): void Gate::define('viewPulse', function (User $user) { return $user->can('system.monitor'); }); + + Passport::defaultScopes([ + 'user:own:read', + ]); + + Passport::tokensCan([ + 'user:own:read' => __('auth.oauth.scopes.user_own_read'), + 'room:own:read' => __('auth.oauth.scopes.room_own_read'), + 'room:create' => __('auth.oauth.scopes.room_create'), + ]); + + // Configure authorization view for OAuth authorization code flow + Passport::authorizationView(function ($parameters) { + return response()->json([ + 'client' => $parameters['client']->name, + 'scopes' => $parameters['scopes'], + 'authToken' => $parameters['authToken'], + ]); + }); + + Passport::tokensExpireIn(CarbonInterval::seconds(config('passport.token_lifetime'))); + Passport::refreshTokensExpireIn(CarbonInterval::seconds(config('passport.refresh_token_lifetime'))); + + $this->validatePassportKeys(); + } + + /** + * Validates the Passport RSA key configuration when the external API is enabled. + * + * Ensures that the required OAuth private and public keys are present and meet + * the following criteria: + * - The private key must be set via the OAUTH_PRIVATE_KEY environment variable. + * - The private key must be a valid RSA key. + * - The RSA key must be at least 4096 bits in length. + * - A public key must be derivable from the provided private key. + * + * @throws ErrorException If any key validation check fails. + */ + private function validatePassportKeys() + { + // Only check if passport / external API is enabled + if (config('passport.enabled')) { + // Ensure the private key environment variable is present + if (! config('passport.private_key')) { + throw new ErrorException('OAUTH_PRIVATE_KEY environment variable is not set.'); + } + + $keyDetails = config('passport.private_key_details'); + + // Ensure the private key was successfully parsed + if (! $keyDetails) { + throw new ErrorException('OAUTH_PRIVATE_KEY environment variable is not a valid private key.'); + } + + // Only RSA keys are supported for OAuth token signing + if ($keyDetails['type'] !== OPENSSL_KEYTYPE_RSA) { + throw new ErrorException('OAUTH_PRIVATE_KEY environment variable is not a valid RSA private key.'); + } + + // Check minimum key size of 4096 bits + if ($keyDetails['bits'] < 4096) { + throw new ErrorException('OAUTH_PRIVATE_KEY environment variable RSA private key must be at least 4096 bytes.'); + } + + // Verify that a public key was successfully derived from the private key + if (! config('passport.public_key')) { + throw new ErrorException('Failed to extract public key from private key. Please check your OAUTH_PRIVATE_KEY environment variable.'); + } + } } } diff --git a/app/Providers/RouteServiceProvider.php b/app/Providers/RouteServiceProvider.php index 13836c9ee..65f59db21 100644 --- a/app/Providers/RouteServiceProvider.php +++ b/app/Providers/RouteServiceProvider.php @@ -39,6 +39,10 @@ public function boot(): void ->prefix('api') ->group(base_path('routes/api.php')); + Route::middleware('external_api') + ->prefix('external-api') + ->group(base_path('routes/external_api.php')); + Route::middleware('web') ->group(base_path('routes/web.php')); }); @@ -59,6 +63,10 @@ protected function configureRateLimiting(): void return Limit::perMinute(5)->by($throttleKey); }); + RateLimiter::for('external_api', function (Request $request) { + return Limit::perMinute(120)->by($request->user()?->id ?: $request->ip()); + }); + RateLimiter::for('password_reset', function (Request $request) { return Limit::perMinutes(30, 5)->by($request->user()?->id ?: $request->ip()); }); diff --git a/composer.json b/composer.json index 9aea3a880..0ea1e589b 100644 --- a/composer.json +++ b/composer.json @@ -11,6 +11,7 @@ "php": "^8.4", "ext-curl": "*", "ext-json": "*", + "ext-openssl": "*", "ext-redis": "*", "ext-simplexml": "*", "ext-zip": "*", @@ -19,6 +20,7 @@ "laravel/fortify": "^1.36", "laravel/framework": "^13.2", "laravel/horizon": "^5.21", + "laravel/passport": "*", "laravel/prompts": "^0.3.2", "laravel/pulse": "^1.0@beta", "laravel/sanctum": "^v4.0", diff --git a/composer.lock b/composer.lock index f7ca22623..0a5903d2e 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "a92b0ee18d08f3151630bb13a2de1dfb", + "content-hash": "2cbf451bab1776a936ea69b7e3b1206f", "packages": [ { "name": "bacon/bacon-qr-code", @@ -396,6 +396,73 @@ }, "time": "2025-09-16T12:23:56+00:00" }, + { + "name": "defuse/php-encryption", + "version": "v2.4.0", + "source": { + "type": "git", + "url": "https://github.com/defuse/php-encryption.git", + "reference": "f53396c2d34225064647a05ca76c1da9d99e5828" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/defuse/php-encryption/zipball/f53396c2d34225064647a05ca76c1da9d99e5828", + "reference": "f53396c2d34225064647a05ca76c1da9d99e5828", + "shasum": "" + }, + "require": { + "ext-openssl": "*", + "paragonie/random_compat": ">= 2", + "php": ">=5.6.0" + }, + "require-dev": { + "phpunit/phpunit": "^5|^6|^7|^8|^9|^10", + "yoast/phpunit-polyfills": "^2.0.0" + }, + "bin": [ + "bin/generate-defuse-key" + ], + "type": "library", + "autoload": { + "psr-4": { + "Defuse\\Crypto\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Hornby", + "email": "taylor@defuse.ca", + "homepage": "https://defuse.ca/" + }, + { + "name": "Scott Arciszewski", + "email": "info@paragonie.com", + "homepage": "https://paragonie.com" + } + ], + "description": "Secure PHP Encryption Library", + "keywords": [ + "aes", + "authenticated encryption", + "cipher", + "crypto", + "cryptography", + "encrypt", + "encryption", + "openssl", + "security", + "symmetric key cryptography" + ], + "support": { + "issues": "https://github.com/defuse/php-encryption/issues", + "source": "https://github.com/defuse/php-encryption/tree/v2.4.0" + }, + "time": "2023-06-19T06:10:36+00:00" + }, { "name": "dflydev/dot-access-data", "version": "v3.0.3", @@ -1147,6 +1214,70 @@ ], "time": "2025-08-08T12:00:00+00:00" }, + { + "name": "firebase/php-jwt", + "version": "v7.0.5", + "source": { + "type": "git", + "url": "https://github.com/googleapis/php-jwt.git", + "reference": "47ad26bab5e7c70ae8a6f08ed25ff83631121380" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/googleapis/php-jwt/zipball/47ad26bab5e7c70ae8a6f08ed25ff83631121380", + "reference": "47ad26bab5e7c70ae8a6f08ed25ff83631121380", + "shasum": "" + }, + "require": { + "php": "^8.0" + }, + "require-dev": { + "guzzlehttp/guzzle": "^7.4", + "phpfastcache/phpfastcache": "^9.2", + "phpspec/prophecy-phpunit": "^2.0", + "phpunit/phpunit": "^9.5", + "psr/cache": "^2.0||^3.0", + "psr/http-client": "^1.0", + "psr/http-factory": "^1.0" + }, + "suggest": { + "ext-sodium": "Support EdDSA (Ed25519) signatures", + "paragonie/sodium_compat": "Support EdDSA (Ed25519) signatures when libsodium is not present" + }, + "type": "library", + "autoload": { + "psr-4": { + "Firebase\\JWT\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Neuman Vong", + "email": "neuman+pear@twilio.com", + "role": "Developer" + }, + { + "name": "Anant Narayanan", + "email": "anant@php.net", + "role": "Developer" + } + ], + "description": "A simple library to encode and decode JSON Web Tokens (JWT) in PHP. Should conform to the current spec.", + "homepage": "https://github.com/firebase/php-jwt", + "keywords": [ + "jwt", + "php" + ], + "support": { + "issues": "https://github.com/googleapis/php-jwt/issues", + "source": "https://github.com/googleapis/php-jwt/tree/v7.0.5" + }, + "time": "2026-04-01T20:38:03+00:00" + }, { "name": "fruitcake/php-cors", "version": "v1.4.0", @@ -2059,6 +2190,81 @@ }, "time": "2026-04-20T18:08:11+00:00" }, + { + "name": "laravel/passport", + "version": "v13.7.5", + "source": { + "type": "git", + "url": "https://github.com/laravel/passport.git", + "reference": "90053dc4ba681c076855779250109bb624f961f6" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/passport/zipball/90053dc4ba681c076855779250109bb624f961f6", + "reference": "90053dc4ba681c076855779250109bb624f961f6", + "shasum": "" + }, + "require": { + "ext-json": "*", + "ext-openssl": "*", + "firebase/php-jwt": "^6.4|^7.0", + "illuminate/auth": "^11.35|^12.0|^13.0", + "illuminate/console": "^11.35|^12.0|^13.0", + "illuminate/container": "^11.35|^12.0|^13.0", + "illuminate/contracts": "^11.35|^12.0|^13.0", + "illuminate/cookie": "^11.35|^12.0|^13.0", + "illuminate/database": "^11.35|^12.0|^13.0", + "illuminate/encryption": "^11.35|^12.0|^13.0", + "illuminate/http": "^11.35|^12.0|^13.0", + "illuminate/support": "^11.35|^12.0|^13.0", + "league/oauth2-server": "^9.2", + "php": "^8.2", + "php-http/discovery": "^1.20", + "phpseclib/phpseclib": "^3.0", + "psr/http-factory-implementation": "*", + "symfony/console": "^7.1|^8.0", + "symfony/psr-http-message-bridge": "^7.1|^8.0" + }, + "require-dev": { + "orchestra/testbench": "^9.15|^10.8|^11.0", + "phpstan/phpstan": "^2.0" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Laravel\\Passport\\PassportServiceProvider" + ] + } + }, + "autoload": { + "psr-4": { + "Laravel\\Passport\\": "src/", + "Laravel\\Passport\\Database\\Factories\\": "database/factories/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + } + ], + "description": "Laravel Passport provides OAuth2 server support to Laravel.", + "keywords": [ + "laravel", + "oauth", + "passport" + ], + "support": { + "issues": "https://github.com/laravel/passport/issues", + "source": "https://github.com/laravel/passport" + }, + "time": "2026-04-16T14:00:29+00:00" + }, { "name": "laravel/prompts", "version": "v0.3.17", @@ -2524,6 +2730,143 @@ }, "time": "2026-03-17T14:54:13+00:00" }, + { + "name": "lcobucci/clock", + "version": "3.6.0", + "source": { + "type": "git", + "url": "https://github.com/lcobucci/clock.git", + "reference": "4cdd88f761e9be9095ccbedf3e08d61ae216c643" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/lcobucci/clock/zipball/4cdd88f761e9be9095ccbedf3e08d61ae216c643", + "reference": "4cdd88f761e9be9095ccbedf3e08d61ae216c643", + "shasum": "" + }, + "require": { + "php": "~8.4.0 || ~8.5.0", + "psr/clock": "^1.0" + }, + "provide": { + "psr/clock-implementation": "1.0" + }, + "require-dev": { + "infection/infection": "^0.32", + "lcobucci/coding-standard": "^12.0", + "phpstan/extension-installer": "^1.3.1", + "phpstan/phpstan": "^2.1", + "phpstan/phpstan-deprecation-rules": "^2.0", + "phpstan/phpstan-phpunit": "^2.0", + "phpstan/phpstan-strict-rules": "^2.0", + "phpunit/phpunit": "^13.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Lcobucci\\Clock\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Luís Cobucci", + "email": "lcobucci@gmail.com" + } + ], + "description": "Yet another clock abstraction", + "support": { + "issues": "https://github.com/lcobucci/clock/issues", + "source": "https://github.com/lcobucci/clock/tree/3.6.0" + }, + "funding": [ + { + "url": "https://github.com/lcobucci", + "type": "github" + }, + { + "url": "https://www.patreon.com/lcobucci", + "type": "patreon" + } + ], + "time": "2026-04-13T21:30:16+00:00" + }, + { + "name": "lcobucci/jwt", + "version": "5.6.0", + "source": { + "type": "git", + "url": "https://github.com/lcobucci/jwt.git", + "reference": "bb3e9f21e4196e8afc41def81ef649c164bca25e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/lcobucci/jwt/zipball/bb3e9f21e4196e8afc41def81ef649c164bca25e", + "reference": "bb3e9f21e4196e8afc41def81ef649c164bca25e", + "shasum": "" + }, + "require": { + "ext-openssl": "*", + "ext-sodium": "*", + "php": "~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0", + "psr/clock": "^1.0" + }, + "require-dev": { + "infection/infection": "^0.29", + "lcobucci/clock": "^3.2", + "lcobucci/coding-standard": "^11.0", + "phpbench/phpbench": "^1.2", + "phpstan/extension-installer": "^1.2", + "phpstan/phpstan": "^1.10.7", + "phpstan/phpstan-deprecation-rules": "^1.1.3", + "phpstan/phpstan-phpunit": "^1.3.10", + "phpstan/phpstan-strict-rules": "^1.5.0", + "phpunit/phpunit": "^11.1" + }, + "suggest": { + "lcobucci/clock": ">= 3.2" + }, + "type": "library", + "autoload": { + "psr-4": { + "Lcobucci\\JWT\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Luís Cobucci", + "email": "lcobucci@gmail.com", + "role": "Developer" + } + ], + "description": "A simple library to work with JSON Web Token and JSON Web Signature", + "keywords": [ + "JWS", + "jwt" + ], + "support": { + "issues": "https://github.com/lcobucci/jwt/issues", + "source": "https://github.com/lcobucci/jwt/tree/5.6.0" + }, + "funding": [ + { + "url": "https://github.com/lcobucci", + "type": "github" + }, + { + "url": "https://www.patreon.com/lcobucci", + "type": "patreon" + } + ], + "time": "2025-10-17T11:30:53+00:00" + }, { "name": "league/commonmark", "version": "2.8.2", @@ -2713,6 +3056,65 @@ ], "time": "2022-12-11T20:36:23+00:00" }, + { + "name": "league/event", + "version": "3.0.3", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/event.git", + "reference": "ec38ff7ea10cad7d99a79ac937fbcffb9334c210" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/event/zipball/ec38ff7ea10cad7d99a79ac937fbcffb9334c210", + "reference": "ec38ff7ea10cad7d99a79ac937fbcffb9334c210", + "shasum": "" + }, + "require": { + "php": ">=7.2.0", + "psr/event-dispatcher": "^1.0" + }, + "provide": { + "psr/event-dispatcher-implementation": "1.0" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "^2.16", + "phpstan/phpstan": "^0.12.45", + "phpunit/phpunit": "^8.5" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.0-dev" + } + }, + "autoload": { + "psr-4": { + "League\\Event\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Frank de Jonge", + "email": "info@frenky.net" + } + ], + "description": "Event package", + "keywords": [ + "emitter", + "event", + "listener" + ], + "support": { + "issues": "https://github.com/thephpleague/event/issues", + "source": "https://github.com/thephpleague/event/tree/3.0.3" + }, + "time": "2024-09-04T16:06:53+00:00" + }, { "name": "league/flysystem", "version": "3.33.0", @@ -2901,6 +3303,102 @@ ], "time": "2024-09-21T08:32:55+00:00" }, + { + "name": "league/oauth2-server", + "version": "9.3.0", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/oauth2-server.git", + "reference": "d8e2f39f645a82b207bbac441694d6e6079357cb" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/oauth2-server/zipball/d8e2f39f645a82b207bbac441694d6e6079357cb", + "reference": "d8e2f39f645a82b207bbac441694d6e6079357cb", + "shasum": "" + }, + "require": { + "defuse/php-encryption": "^2.4", + "ext-json": "*", + "ext-openssl": "*", + "lcobucci/clock": "^2.3 || ^3.0", + "lcobucci/jwt": "^5.0", + "league/event": "^3.0", + "league/uri": "^7.0", + "php": "~8.1.0 || ~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0", + "psr/http-message": "^2.0", + "psr/http-server-middleware": "^1.0" + }, + "replace": { + "league/oauth2server": "*", + "lncd/oauth2": "*" + }, + "require-dev": { + "laminas/laminas-diactoros": "^3.5", + "php-parallel-lint/php-parallel-lint": "^1.3.2", + "phpstan/extension-installer": "^1.3.1", + "phpstan/phpstan": "^1.12|^2.0", + "phpstan/phpstan-deprecation-rules": "^1.1.4|^2.0", + "phpstan/phpstan-phpunit": "^1.3.15|^2.0", + "phpstan/phpstan-strict-rules": "^1.5.2|^2.0", + "phpunit/phpunit": "^10.5|^11.5|^12.0", + "roave/security-advisories": "dev-master", + "slevomat/coding-standard": "^8.14.1", + "squizlabs/php_codesniffer": "^3.8" + }, + "type": "library", + "autoload": { + "psr-4": { + "League\\OAuth2\\Server\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Alex Bilbie", + "email": "hello@alexbilbie.com", + "homepage": "http://www.alexbilbie.com", + "role": "Developer" + }, + { + "name": "Andy Millington", + "email": "andrew@noexceptions.io", + "homepage": "https://www.noexceptions.io", + "role": "Developer" + } + ], + "description": "A lightweight and powerful OAuth 2.0 authorization and resource server library with support for all the core specification grants. This library will allow you to secure your API with OAuth and allow your applications users to approve apps that want to access their data from your API.", + "homepage": "https://oauth2.thephpleague.com/", + "keywords": [ + "Authentication", + "api", + "auth", + "authorisation", + "authorization", + "oauth", + "oauth 2", + "oauth 2.0", + "oauth2", + "protect", + "resource", + "secure", + "server" + ], + "support": { + "issues": "https://github.com/thephpleague/oauth2-server/issues", + "source": "https://github.com/thephpleague/oauth2-server/tree/9.3.0" + }, + "funding": [ + { + "url": "https://github.com/sephster", + "type": "github" + } + ], + "time": "2025-11-25T22:51:15+00:00" + }, { "name": "league/uri", "version": "7.8.1", @@ -4198,39 +4696,168 @@ ], "authors": [ { - "name": "Paragon Initiative Enterprises", - "email": "security@paragonie.com", - "homepage": "https://paragonie.com", - "role": "Maintainer" - }, - { - "name": "Steve 'Sc00bz' Thomas", - "email": "steve@tobtu.com", - "homepage": "https://www.tobtu.com", - "role": "Original Developer" + "name": "Paragon Initiative Enterprises", + "email": "security@paragonie.com", + "homepage": "https://paragonie.com", + "role": "Maintainer" + }, + { + "name": "Steve 'Sc00bz' Thomas", + "email": "steve@tobtu.com", + "homepage": "https://www.tobtu.com", + "role": "Original Developer" + } + ], + "description": "Constant-time Implementations of RFC 4648 Encoding (Base-64, Base-32, Base-16)", + "keywords": [ + "base16", + "base32", + "base32_decode", + "base32_encode", + "base64", + "base64_decode", + "base64_encode", + "bin2hex", + "encoding", + "hex", + "hex2bin", + "rfc4648" + ], + "support": { + "email": "info@paragonie.com", + "issues": "https://github.com/paragonie/constant_time_encoding/issues", + "source": "https://github.com/paragonie/constant_time_encoding" + }, + "time": "2025-09-24T15:06:41+00:00" + }, + { + "name": "paragonie/random_compat", + "version": "v9.99.100", + "source": { + "type": "git", + "url": "https://github.com/paragonie/random_compat.git", + "reference": "996434e5492cb4c3edcb9168db6fbb1359ef965a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/paragonie/random_compat/zipball/996434e5492cb4c3edcb9168db6fbb1359ef965a", + "reference": "996434e5492cb4c3edcb9168db6fbb1359ef965a", + "shasum": "" + }, + "require": { + "php": ">= 7" + }, + "require-dev": { + "phpunit/phpunit": "4.*|5.*", + "vimeo/psalm": "^1" + }, + "suggest": { + "ext-libsodium": "Provides a modern crypto API that can be used to generate random bytes." + }, + "type": "library", + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Paragon Initiative Enterprises", + "email": "security@paragonie.com", + "homepage": "https://paragonie.com" + } + ], + "description": "PHP 5.x polyfill for random_bytes() and random_int() from PHP 7", + "keywords": [ + "csprng", + "polyfill", + "pseudorandom", + "random" + ], + "support": { + "email": "info@paragonie.com", + "issues": "https://github.com/paragonie/random_compat/issues", + "source": "https://github.com/paragonie/random_compat" + }, + "time": "2020-10-15T08:29:30+00:00" + }, + { + "name": "php-http/discovery", + "version": "1.20.0", + "source": { + "type": "git", + "url": "https://github.com/php-http/discovery.git", + "reference": "82fe4c73ef3363caed49ff8dd1539ba06044910d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-http/discovery/zipball/82fe4c73ef3363caed49ff8dd1539ba06044910d", + "reference": "82fe4c73ef3363caed49ff8dd1539ba06044910d", + "shasum": "" + }, + "require": { + "composer-plugin-api": "^1.0|^2.0", + "php": "^7.1 || ^8.0" + }, + "conflict": { + "nyholm/psr7": "<1.0", + "zendframework/zend-diactoros": "*" + }, + "provide": { + "php-http/async-client-implementation": "*", + "php-http/client-implementation": "*", + "psr/http-client-implementation": "*", + "psr/http-factory-implementation": "*", + "psr/http-message-implementation": "*" + }, + "require-dev": { + "composer/composer": "^1.0.2|^2.0", + "graham-campbell/phpspec-skip-example-extension": "^5.0", + "php-http/httplug": "^1.0 || ^2.0", + "php-http/message-factory": "^1.0", + "phpspec/phpspec": "^5.1 || ^6.1 || ^7.3", + "sebastian/comparator": "^3.0.5 || ^4.0.8", + "symfony/phpunit-bridge": "^6.4.4 || ^7.0.1" + }, + "type": "composer-plugin", + "extra": { + "class": "Http\\Discovery\\Composer\\Plugin", + "plugin-optional": true + }, + "autoload": { + "psr-4": { + "Http\\Discovery\\": "src/" + }, + "exclude-from-classmap": [ + "src/Composer/Plugin.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Márk Sági-Kazár", + "email": "mark.sagikazar@gmail.com" } ], - "description": "Constant-time Implementations of RFC 4648 Encoding (Base-64, Base-32, Base-16)", + "description": "Finds and installs PSR-7, PSR-17, PSR-18 and HTTPlug implementations", + "homepage": "http://php-http.org", "keywords": [ - "base16", - "base32", - "base32_decode", - "base32_encode", - "base64", - "base64_decode", - "base64_encode", - "bin2hex", - "encoding", - "hex", - "hex2bin", - "rfc4648" + "adapter", + "client", + "discovery", + "factory", + "http", + "message", + "psr17", + "psr7" ], "support": { - "email": "info@paragonie.com", - "issues": "https://github.com/paragonie/constant_time_encoding/issues", - "source": "https://github.com/paragonie/constant_time_encoding" + "issues": "https://github.com/php-http/discovery/issues", + "source": "https://github.com/php-http/discovery/tree/1.20.0" }, - "time": "2025-09-24T15:06:41+00:00" + "time": "2024-10-02T11:20:13+00:00" }, { "name": "phpdocumentor/reflection-common", @@ -4526,6 +5153,116 @@ ], "time": "2025-12-27T19:41:33+00:00" }, + { + "name": "phpseclib/phpseclib", + "version": "3.0.52", + "source": { + "type": "git", + "url": "https://github.com/phpseclib/phpseclib.git", + "reference": "2adaefc83df2ec548558307690f376dd7d4f4fce" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpseclib/phpseclib/zipball/2adaefc83df2ec548558307690f376dd7d4f4fce", + "reference": "2adaefc83df2ec548558307690f376dd7d4f4fce", + "shasum": "" + }, + "require": { + "paragonie/constant_time_encoding": "^1|^2|^3", + "paragonie/random_compat": "^1.4|^2.0|^9.99.99", + "php": ">=5.6.1" + }, + "require-dev": { + "phpunit/phpunit": "*" + }, + "suggest": { + "ext-dom": "Install the DOM extension to load XML formatted public keys.", + "ext-gmp": "Install the GMP (GNU Multiple Precision) extension in order to speed up arbitrary precision integer arithmetic operations.", + "ext-libsodium": "SSH2/SFTP can make use of some algorithms provided by the libsodium-php extension.", + "ext-mcrypt": "Install the Mcrypt extension in order to speed up a few other cryptographic operations.", + "ext-openssl": "Install the OpenSSL extension in order to speed up a wide variety of cryptographic operations." + }, + "type": "library", + "autoload": { + "files": [ + "phpseclib/bootstrap.php" + ], + "psr-4": { + "phpseclib3\\": "phpseclib/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jim Wigginton", + "email": "terrafrost@php.net", + "role": "Lead Developer" + }, + { + "name": "Patrick Monnerat", + "email": "pm@datasphere.ch", + "role": "Developer" + }, + { + "name": "Andreas Fischer", + "email": "bantu@phpbb.com", + "role": "Developer" + }, + { + "name": "Hans-Jürgen Petrich", + "email": "petrich@tronic-media.com", + "role": "Developer" + }, + { + "name": "Graham Campbell", + "email": "graham@alt-three.com", + "role": "Developer" + } + ], + "description": "PHP Secure Communications Library - Pure-PHP implementations of RSA, AES, SSH2, SFTP, X.509 etc.", + "homepage": "http://phpseclib.sourceforge.net", + "keywords": [ + "BigInteger", + "aes", + "asn.1", + "asn1", + "blowfish", + "crypto", + "cryptography", + "encryption", + "rsa", + "security", + "sftp", + "signature", + "signing", + "ssh", + "twofish", + "x.509", + "x509" + ], + "support": { + "issues": "https://github.com/phpseclib/phpseclib/issues", + "source": "https://github.com/phpseclib/phpseclib/tree/3.0.52" + }, + "funding": [ + { + "url": "https://github.com/terrafrost", + "type": "github" + }, + { + "url": "https://www.patreon.com/phpseclib", + "type": "patreon" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpseclib/phpseclib", + "type": "tidelift" + } + ], + "time": "2026-04-27T07:02:15+00:00" + }, { "name": "phpstan/phpdoc-parser", "version": "2.3.2", @@ -5086,6 +5823,119 @@ }, "time": "2023-04-04T09:54:51+00:00" }, + { + "name": "psr/http-server-handler", + "version": "1.0.2", + "source": { + "type": "git", + "url": "https://github.com/php-fig/http-server-handler.git", + "reference": "84c4fb66179be4caaf8e97bd239203245302e7d4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/http-server-handler/zipball/84c4fb66179be4caaf8e97bd239203245302e7d4", + "reference": "84c4fb66179be4caaf8e97bd239203245302e7d4", + "shasum": "" + }, + "require": { + "php": ">=7.0", + "psr/http-message": "^1.0 || ^2.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Http\\Server\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for HTTP server-side request handler", + "keywords": [ + "handler", + "http", + "http-interop", + "psr", + "psr-15", + "psr-7", + "request", + "response", + "server" + ], + "support": { + "source": "https://github.com/php-fig/http-server-handler/tree/1.0.2" + }, + "time": "2023-04-10T20:06:20+00:00" + }, + { + "name": "psr/http-server-middleware", + "version": "1.0.2", + "source": { + "type": "git", + "url": "https://github.com/php-fig/http-server-middleware.git", + "reference": "c1481f747daaa6a0782775cd6a8c26a1bf4a3829" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/http-server-middleware/zipball/c1481f747daaa6a0782775cd6a8c26a1bf4a3829", + "reference": "c1481f747daaa6a0782775cd6a8c26a1bf4a3829", + "shasum": "" + }, + "require": { + "php": ">=7.0", + "psr/http-message": "^1.0 || ^2.0", + "psr/http-server-handler": "^1.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Http\\Server\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for HTTP server-side middleware", + "keywords": [ + "http", + "http-interop", + "middleware", + "psr", + "psr-15", + "psr-7", + "request", + "response" + ], + "support": { + "issues": "https://github.com/php-fig/http-server-middleware/issues", + "source": "https://github.com/php-fig/http-server-middleware/tree/1.0.2" + }, + "time": "2023-04-11T06:14:47+00:00" + }, { "name": "psr/log", "version": "3.0.2", @@ -8623,6 +9473,93 @@ ], "time": "2026-03-30T15:14:47+00:00" }, + { + "name": "symfony/psr-http-message-bridge", + "version": "v8.0.8", + "source": { + "type": "git", + "url": "https://github.com/symfony/psr-http-message-bridge.git", + "reference": "94facc221260c1d5f20e31ee43cd6c6a824b4a19" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/psr-http-message-bridge/zipball/94facc221260c1d5f20e31ee43cd6c6a824b4a19", + "reference": "94facc221260c1d5f20e31ee43cd6c6a824b4a19", + "shasum": "" + }, + "require": { + "php": ">=8.4", + "psr/http-message": "^1.0|^2.0", + "symfony/http-foundation": "^7.4|^8.0" + }, + "conflict": { + "php-http/discovery": "<1.15" + }, + "require-dev": { + "nyholm/psr7": "^1.1", + "php-http/discovery": "^1.15", + "psr/log": "^1.1.4|^2|^3", + "symfony/browser-kit": "^7.4|^8.0", + "symfony/config": "^7.4|^8.0", + "symfony/event-dispatcher": "^7.4|^8.0", + "symfony/framework-bundle": "^7.4|^8.0", + "symfony/http-kernel": "^7.4|^8.0", + "symfony/runtime": "^7.4|^8.0" + }, + "type": "symfony-bridge", + "autoload": { + "psr-4": { + "Symfony\\Bridge\\PsrHttpMessage\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "PSR HTTP message bridge", + "homepage": "https://symfony.com", + "keywords": [ + "http", + "http-message", + "psr-17", + "psr-7" + ], + "support": { + "source": "https://github.com/symfony/psr-http-message-bridge/tree/v8.0.8" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-03-30T15:14:47+00:00" + }, { "name": "symfony/routing", "version": "v8.0.9", @@ -12268,6 +13205,7 @@ "php": "^8.4", "ext-curl": "*", "ext-json": "*", + "ext-openssl": "*", "ext-redis": "*", "ext-simplexml": "*", "ext-zip": "*" diff --git a/config/auth.php b/config/auth.php index 1f8b29047..4aee0f80c 100644 --- a/config/auth.php +++ b/config/auth.php @@ -48,6 +48,10 @@ 'driver' => 'session', 'provider' => 'users', ], + 'oauth_external' => [ + 'driver' => 'passport', + 'provider' => 'users', + ], ], /* diff --git a/config/cors.php b/config/cors.php new file mode 100644 index 000000000..aa8e429b7 --- /dev/null +++ b/config/cors.php @@ -0,0 +1,36 @@ + ['oauth/*', 'external-api/v1/*'], + + 'allowed_methods' => ['*'], + + 'allowed_origins' => ['*'], + + 'allowed_origins_patterns' => [], + + 'allowed_headers' => ['*'], + + 'exposed_headers' => [], + + 'max_age' => 0, + + 'supports_credentials' => false, + +]; diff --git a/config/passport.php b/config/passport.php new file mode 100644 index 000000000..b444399bd --- /dev/null +++ b/config/passport.php @@ -0,0 +1,65 @@ + $enabled, + + /* + |-------------------------------------------------------------------------- + | Passport Guard + |-------------------------------------------------------------------------- + | + | Here you may specify which authentication guard Passport will use when + | authenticating users. This value should correspond with one of your + | guards that is already present in your "auth" configuration file. + | + */ + + 'guard' => null, + + 'middleware' => [], + + /* + |-------------------------------------------------------------------------- + | Encryption Keys + |-------------------------------------------------------------------------- + | + | Passport uses encryption keys while generating secure access tokens for + | your application. By default, the keys are stored as local files but + | can be set via environment variables when that is more convenient. + | + */ + + 'private_key_details' => $privateKeyDetails, + + 'private_key' => $privateKeyPem, + 'public_key' => $publicKeyPem, + + 'token_lifetime' => (int) env('OAUTH_TOKEN_LIFETIME', 60 * 60), + 'refresh_token_lifetime' => (int) env('OAUTH_REFRESH_LIFETIME', 365 * 24 * 60 * 60), +]; diff --git a/database/migrations/2016_06_01_000001_create_oauth_auth_codes_table.php b/database/migrations/2016_06_01_000001_create_oauth_auth_codes_table.php new file mode 100644 index 000000000..4e2f4852a --- /dev/null +++ b/database/migrations/2016_06_01_000001_create_oauth_auth_codes_table.php @@ -0,0 +1,41 @@ +char('id', 80)->primary(); + $table->foreignId('user_id')->index(); + $table->foreignUuid('client_id'); + $table->text('scopes')->nullable(); + $table->boolean('revoked'); + $table->dateTime('expires_at')->nullable(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('oauth_auth_codes'); + } + + /** + * Get the migration connection name. + */ + public function getConnection(): ?string + { + return $this->connection ?? config('passport.connection'); + } +}; diff --git a/database/migrations/2016_06_01_000002_create_oauth_access_tokens_table.php b/database/migrations/2016_06_01_000002_create_oauth_access_tokens_table.php new file mode 100644 index 000000000..ca7b9da39 --- /dev/null +++ b/database/migrations/2016_06_01_000002_create_oauth_access_tokens_table.php @@ -0,0 +1,43 @@ +char('id', 80)->primary(); + $table->foreignId('user_id')->nullable()->index(); + $table->foreignUuid('client_id'); + $table->string('name')->nullable(); + $table->text('scopes')->nullable(); + $table->boolean('revoked'); + $table->timestamps(); + $table->dateTime('expires_at')->nullable(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('oauth_access_tokens'); + } + + /** + * Get the migration connection name. + */ + public function getConnection(): ?string + { + return $this->connection ?? config('passport.connection'); + } +}; diff --git a/database/migrations/2016_06_01_000003_create_oauth_refresh_tokens_table.php b/database/migrations/2016_06_01_000003_create_oauth_refresh_tokens_table.php new file mode 100644 index 000000000..5c926f4db --- /dev/null +++ b/database/migrations/2016_06_01_000003_create_oauth_refresh_tokens_table.php @@ -0,0 +1,39 @@ +char('id', 80)->primary(); + $table->char('access_token_id', 80)->index(); + $table->boolean('revoked'); + $table->dateTime('expires_at')->nullable(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('oauth_refresh_tokens'); + } + + /** + * Get the migration connection name. + */ + public function getConnection(): ?string + { + return $this->connection ?? config('passport.connection'); + } +}; diff --git a/database/migrations/2016_06_01_000004_create_oauth_clients_table.php b/database/migrations/2016_06_01_000004_create_oauth_clients_table.php new file mode 100644 index 000000000..393ce8a03 --- /dev/null +++ b/database/migrations/2016_06_01_000004_create_oauth_clients_table.php @@ -0,0 +1,44 @@ +uuid('id')->primary(); + $table->nullableMorphs('owner'); + $table->string('name'); + $table->string('secret')->nullable(); + $table->string('provider')->nullable(); + $table->text('redirect_uris'); + $table->text('grant_types'); + $table->boolean('revoked'); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('oauth_clients'); + } + + /** + * Get the migration connection name. + */ + public function getConnection(): ?string + { + return $this->connection ?? config('passport.connection'); + } +}; diff --git a/database/migrations/2024_06_01_000001_create_oauth_device_codes_table.php b/database/migrations/2024_06_01_000001_create_oauth_device_codes_table.php new file mode 100644 index 000000000..494720eb1 --- /dev/null +++ b/database/migrations/2024_06_01_000001_create_oauth_device_codes_table.php @@ -0,0 +1,44 @@ +char('id', 80)->primary(); + $table->foreignId('user_id')->nullable()->index(); + $table->foreignUuid('client_id')->index(); + $table->char('user_code', 8)->unique(); + $table->text('scopes'); + $table->boolean('revoked'); + $table->dateTime('user_approved_at')->nullable(); + $table->dateTime('last_polled_at')->nullable(); + $table->dateTime('expires_at')->nullable(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('oauth_device_codes'); + } + + /** + * Get the migration connection name. + */ + public function getConnection(): ?string + { + return $this->connection ?? config('passport.connection'); + } +}; diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php index 2cdc43d90..66e2d3183 100644 --- a/database/seeders/DatabaseSeeder.php +++ b/database/seeders/DatabaseSeeder.php @@ -18,5 +18,6 @@ public function run() $this->call(RoomTypeSeeder::class); $this->call(RolesAndPermissionsSeeder::class); $this->call(ServerPoolSeeder::class); + $this->call(OAuthClientSeeder::class); } } diff --git a/database/seeders/OAuthClientSeeder.php b/database/seeders/OAuthClientSeeder.php new file mode 100644 index 000000000..9ca592676 --- /dev/null +++ b/database/seeders/OAuthClientSeeder.php @@ -0,0 +1,29 @@ +createThunderbirdClient($clientRepository); + } + + private function createThunderbirdClient(ClientRepository $clientRepository): void + { + // Fixed UUID for Thunderbird Client + $thunderbirdUUID = '019d2999-80a3-73e3-9905-263deb882b25'; + + // Only create client if it doesn't already exist + if (! $clientRepository->find($thunderbirdUUID)) { + $client = $clientRepository->createAuthorizationCodeGrantClient('Thunderbird', ['http://127.0.0.1/mozoauth2/'], false); + $client->id = $thunderbirdUUID; + $client->save(); + } + } +} diff --git a/docker/app/Dockerfile b/docker/app/Dockerfile index ae8d87534..116f1cc02 100644 --- a/docker/app/Dockerfile +++ b/docker/app/Dockerfile @@ -1,3 +1,9 @@ +FROM node:25-alpine AS docs + +WORKDIR /docs +COPY ./docs/ ./ +RUN npm install && npm run build + FROM php:8.4-fpm-alpine LABEL maintainer="Samuel Weirich" @@ -89,7 +95,7 @@ RUN mkdir -p /var/log/php COPY ./docker/app/ldap/ /etc/openldap # Copy application files -COPY --chown=www-data:www-data ./ /var/www/html +COPY --exclude=docs --chown=www-data:www-data ./ /var/www/html # Add folder to git safe.directory RUN su-exec www-data git config --global --add safe.directory /var/www/html @@ -108,6 +114,9 @@ RUN if [ "$VITE_COVERAGE" != "true" ] ; then \ RUN su-exec www-data composer install --no-dev RUN su-exec www-data composer dump-autoload -o +# Copy docs (Only for this branch, REMOVE before merge!) from the first stage +COPY --chown=www-data:www-data --from=docs /docs/build/ /var/www/html/public/PILOS/ + EXPOSE 80 EXPOSE 443 diff --git a/docker/app/nginx/sites-enabled/default.conf b/docker/app/nginx/sites-enabled/default.conf index 423f2d790..5ba7e341e 100644 --- a/docker/app/nginx/sites-enabled/default.conf +++ b/docker/app/nginx/sites-enabled/default.conf @@ -12,7 +12,7 @@ server { include /etc/nginx/snippets/security-header.conf; - index index.php; + index index.php index.html; charset utf-8; diff --git a/docs/docs/administration/08-advanced/10-external-api.mdx b/docs/docs/administration/08-advanced/10-external-api.mdx new file mode 100644 index 000000000..ab40ea8c1 --- /dev/null +++ b/docs/docs/administration/08-advanced/10-external-api.mdx @@ -0,0 +1,107 @@ +--- +title: External API +description: Guide on how to connect third-party applications to PILOS +hide_table_of_contents: true +--- + +import ApiDocMdx from "@theme/ApiDocMdx"; +import openApi from "../../../openapi/openapi.json"; + +:::note + +This is a new experimental feature and therefore not enabled by default. + +::: + +## Introduction + +PILOS supports third-party applications to connect to the PILOS using a dedicated API. +This API is different from the internal API used by the frontend and still limited in its capabilities. + +## Authentication and Authorization + +Third party applications can authenticate themselves using OAuth 2.0. +User can grant different scopes to the third-party applications, which allows them to access different parts of the PILOS API. + +Scopes limit the capabilities of the third-party applications, however the permissions inherited to the user +based on his role(s) are still applied. Therefore, an access token with a given scope does not necessarily allow the third-party application to perform any action. + +## Versioning + +The external API will be subject to change. All routes are currently prefixed with `/external-api/v1`. + +You can call `GET /external-api` to receive the current version and check if the external api is enabled. +:::note +In case the route doesn't respond with `content-type: application/json`, the PILOS version you are using does not support the external API. +::: + +```json +{ + "version": "1", + "enabled": true +} +``` + +## Configuration + +To enable OAuth 2.0 authentication and the external API, the following environment variables have to be set: + +```bash +OAUTH_ENABLED=true +``` + +Next, create a RSA private keys for the OAuth 2.0 authentication using OpenSSL. + +```bash +openssl genrsa 4096 +``` + +Store the generated private key in the `OAUTH_PRIVATE_KEY` environment variable: + +```bash +OAUTH_PRIVATE_KEY="-----BEGIN RSA PRIVATE KEY----- + +-----END RSA PRIVATE KEY-----" +``` + +:::warning + +Overwriting an existing key will invalidate all existing access tokens, which will require all third-party applications to re-authenticate. + +::: + +## OAuth 2.0 + +### Supported grant types + +- Authorization Code Grant +- Authorization Code Grant with PKCE + +### Endpoints + +- Authorization Endpoint: `/oauth/authorize` +- Token Endpoint: `/oauth/token` + +## Clients + +Third-party applications can be registered as clients in PILOS. +To register a new client, you can use the `pilos-cli` tool: + +```bash +docker compose exec app pilos-cli passport:client +``` + +To create a public client, pass the `--public` flag: + +```bash +docker compose exec app pilos-cli passport:client --public +``` + +### Official clients + +- Thunderbird Add-on + - Client-ID: `019d2999-80a3-73e3-9905-263deb882b25` + +## API Documentation + + diff --git a/docs/docusaurus.config.js b/docs/docusaurus.config.js index 1738812e4..90128450a 100644 --- a/docs/docusaurus.config.js +++ b/docs/docusaurus.config.js @@ -55,6 +55,7 @@ const config = { }, }), ], + ["redocusaurus", {}], ], future: { @@ -62,7 +63,7 @@ const config = { removeLegacyPostBuildHeadAttribute: true, useCssCascadeLayers: true, }, - experimental_faster: { + faster: { ssgWorkerThreads: true, }, }, @@ -152,6 +153,11 @@ const config = { "scss", ], }, + docs: { + sidebar: { + hideable: true, + }, + }, }), }; diff --git a/docs/openapi/openapi.json b/docs/openapi/openapi.json new file mode 100644 index 000000000..67ace226a --- /dev/null +++ b/docs/openapi/openapi.json @@ -0,0 +1,665 @@ +{ + "openapi": "3.0.0", + "info": { + "title": "PILOS External API", + "version": "1.0.0", + "description": "OpenAPI specification for the external OAuth-protected API routes under /external-api/v1.\n" + }, + "servers": [ + { + "url": "/external-api/v1" + } + ], + "paths": { + "/room_types": { + "get": { + "tags": ["Room Types"], + "operationId": "listExternalRoomTypes", + "summary": "List room types", + "description": "Returns available room types.\n", + "security": [ + { + "oauth2External": ["room:create"] + }, + { + "oauth2External": ["room:own:read"] + } + ], + "parameters": [ + { + "name": "filter", + "in": "query", + "required": false, + "description": "* `own`: Only room types the authenticated user can use\n", + "schema": { + "type": "string", + "enum": ["own"] + }, + "example": "own" + } + ], + "responses": { + "200": { + "description": "Room types loaded successfully.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "type": "array", + "description": "List of room types.", + "items": { + "$ref": "#/components/schemas/RoomType" + } + } + } + }, + "examples": { + "success": { + "summary": "Room types response", + "value": { + "data": [ + { + "id": 1, + "name": "Lecture", + "description": "Room type for lecture sessions", + "room_settings": { + "has_access_code_default": true, + "has_access_code_enforced": false, + "has_allow_guests_default": true, + "has_allow_guests_enforced": false + } + }, + { + "id": 2, + "name": "Seminar", + "description": "Restricted seminar room", + "room_settings": { + "has_access_code_default": false, + "has_access_code_enforced": true, + "has_allow_guests_default": false, + "has_allow_guests_enforced": true + } + } + ] + } + } + } + } + } + }, + "401": { + "$ref": "#/components/responses/UnauthorizedError" + }, + "403": { + "$ref": "#/components/responses/ForbiddenError" + }, + "422": { + "$ref": "#/components/responses/ValidationError" + }, + "429": { + "$ref": "#/components/responses/TooManyRequestsError" + } + } + } + }, + "/rooms": { + "get": { + "tags": ["Rooms"], + "operationId": "listExternalRooms", + "summary": "List own rooms", + "description": "Returns all rooms owned by the authenticated user.", + "security": [ + { + "oauth2External": ["room:own:read"] + } + ], + "responses": { + "200": { + "description": "Rooms loaded successfully.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "type": "array", + "description": "List of rooms.", + "items": { + "$ref": "#/components/schemas/Room" + } + } + } + }, + "examples": { + "success": { + "summary": "Room list response", + "value": { + "data": [ + { + "id": "abc-123-def", + "name": "External Room", + "room_type": { + "id": 1, + "name": "Lecture", + "description": "Room type for lecture sessions", + "room_settings": { + "has_access_code_default": true, + "has_access_code_enforced": false, + "has_allow_guests_default": true, + "has_allow_guests_enforced": false + } + }, + "access_code": "123456789", + "allow_guests": true, + "link": "https://example.org/rooms/abc-123-def" + }, + { + "id": "hij-987-klm", + "name": "External Room", + "room_type": { + "id": 2, + "name": "Seminar", + "description": "Restricted seminar room", + "room_settings": { + "has_access_code_default": false, + "has_access_code_enforced": true, + "has_allow_guests_default": false, + "has_allow_guests_enforced": true + } + }, + "access_code": null, + "allow_guests": false, + "link": "https://example.org/rooms/hij-987-klm" + } + ] + } + } + } + } + } + }, + "401": { + "$ref": "#/components/responses/UnauthorizedError" + }, + "403": { + "$ref": "#/components/responses/ForbiddenError" + }, + "429": { + "$ref": "#/components/responses/TooManyRequestsError" + } + } + }, + "post": { + "tags": ["Rooms"], + "operationId": "createExternalRoom", + "summary": "Create a room", + "description": "Creates a new room for the authenticated user.", + "security": [ + { + "oauth2External": ["room:create"] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateRoomRequest" + }, + "examples": { + "validRequest": { + "summary": "Create room request", + "value": { + "name": "External Room", + "room_type": 1, + "access_code": "123456789", + "allow_guests": true + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Room created successfully.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RoomSingleResponse" + }, + "examples": { + "success": { + "summary": "Room create response", + "value": { + "data": { + "id": "abc-123-def", + "name": "External Room", + "room_type": { + "id": 1, + "name": "Lecture", + "description": "Room type for lecture sessions", + "room_settings": { + "has_access_code_default": true, + "has_access_code_enforced": false, + "has_allow_guests_default": true, + "has_allow_guests_enforced": false + } + }, + "access_code": "123456789", + "allow_guests": true, + "link": "https://example.org/rooms/abc-123-def" + } + } + } + } + } + } + }, + "401": { + "$ref": "#/components/responses/UnauthorizedError" + }, + "403": { + "$ref": "#/components/responses/ForbiddenError" + }, + "422": { + "$ref": "#/components/responses/ValidationError" + }, + "429": { + "description": "Room creation is rate-limited or room limit exceeded.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorMessage" + }, + "examples": { + "tooManyRequests": { + "summary": "General rate limit", + "value": { + "message": "Too Many Attempts." + } + }, + "roomLimitExceeded": { + "summary": "Room limit exceeded", + "value": { + "message": "Creating room failed! You have reached the max. amount of rooms." + } + } + } + } + } + } + } + } + }, + "/me": { + "get": { + "tags": ["Authentication"], + "operationId": "getCurrentUserDetails", + "summary": "Get details about the user associcated with the token", + "security": [ + { + "oauth2External": ["user:own:read"] + } + ], + "responses": { + "200": { + "description": "Current user loaded successfully.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "$ref": "#/components/schemas/CurrentUser" + } + } + } + } + } + }, + "401": { + "$ref": "#/components/responses/UnauthorizedError" + }, + "403": { + "$ref": "#/components/responses/ForbiddenError" + }, + "429": { + "$ref": "#/components/responses/TooManyRequestsError" + } + } + } + } + }, + "components": { + "securitySchemes": { + "oauth2External": { + "type": "oauth2", + "description": "OAuth 2.0", + "flows": { + "authorizationCode": { + "authorizationUrl": "/oauth/authorize", + "tokenUrl": "/oauth/token", + "scopes": { + "room:own:read": "Read rooms owned by the authenticated user.", + "room:create": "Create rooms.", + "user:own:read": "Read details about the authenticated user." + } + } + } + } + }, + "responses": { + "UnauthorizedError": { + "description": "Authentication is required or bearer token is invalid.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorMessage" + }, + "examples": { + "unauthorized": { + "value": { + "message": "Unauthenticated." + } + } + } + } + } + }, + "ForbiddenError": { + "description": "The token is authenticated but misses required scope(s).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorMessage" + }, + "examples": { + "forbidden": { + "value": { + "message": "This action is unauthorized." + } + } + } + } + } + }, + "ValidationError": { + "description": "Request validation failed.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidationErrorResponse" + }, + "examples": { + "invalidFilter": { + "value": { + "message": "The selected filter is invalid.", + "errors": { + "filter": ["The selected filter is invalid."] + } + } + }, + "invalidCreatePayload": { + "value": { + "message": "The given data was invalid.", + "errors": { + "name": ["The name field is required."], + "room_type": ["The selected room type is invalid."], + "access_code": ["The access code must be 9 digits."] + } + } + } + } + } + } + }, + "TooManyRequestsError": { + "description": "Too many requests.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorMessage" + }, + "examples": { + "tooManyRequests": { + "value": { + "message": "Too Many Attempts." + } + } + } + } + } + } + }, + "schemas": { + "CurrentUser": { + "type": "object", + "description": "Public representation of the user represented by the token.", + "required": [ + "id", + "image", + "email", + "firstname", + "lastname", + "locale", + "room_limit", + "timezone", + "permissions" + ], + "properties": { + "id": { + "type": "integer", + "description": "Unique user identifier.", + "example": 1 + }, + "image": { + "type": "string", + "description": "URL to the profile picture.", + "example": "https://example.org/storage/profile_images/ruuQxYgDXWDid2gIb8uMUwjUiqaJRO8tX36ye9Lt.jpg" + }, + "email": { + "type": "string", + "description": "Email.", + "example": "john.doe@example.org" + }, + "firstname": { + "type": "string", + "description": "First name.", + "example": "John" + }, + "lastname": { + "type": "string", + "description": "Last name.", + "example": "Doe" + }, + "locale": { + "type": "string", + "description": "Locale of the user.", + "example": "de" + }, + "room_limit": { + "type": "integer", + "description": "Max. amount of rooms the user can have; -1: unlimited.", + "example": 5 + }, + "timezone": { + "type": "string", + "description": "Timezone of the user.", + "example": "Europe/Berlin" + }, + "permissions": { + "type": "array", + "description": "List of permissions the user has.", + "items": { + "type": "string" + }, + "example": ["rooms.create"] + } + } + }, + "RoomType": { + "type": "object", + "description": "Public representation of a room type usable in the external API.", + "required": ["id", "name", "description", "room_settings"], + "properties": { + "id": { + "type": "integer", + "description": "Unique room type identifier." + }, + "name": { + "type": "string", + "description": "Human-readable room type name." + }, + "description": { + "type": "string", + "description": "Description of the room type." + }, + "room_settings": { + "$ref": "#/components/schemas/RoomSettings" + } + } + }, + "RoomSettings": { + "type": "object", + "description": "Default and enforced room settings inherited from the room type.", + "required": [ + "has_access_code_default", + "has_access_code_enforced", + "has_allow_guests_default", + "has_allow_guests_enforced" + ], + "properties": { + "has_access_code_default": { + "type": "boolean", + "description": "Whether the room should have an access code by default" + }, + "has_access_code_enforced": { + "type": "boolean", + "description": "Whether has_access_code_default value is enforced" + }, + "has_allow_guests_default": { + "type": "boolean", + "description": "Whether the room should allow guests by default" + }, + "has_allow_guests_enforced": { + "type": "boolean", + "description": "Whether has_allow_guests_default value is enforced" + } + } + }, + "Room": { + "type": "object", + "description": "Room.", + "required": [ + "id", + "name", + "room_type", + "access_code", + "allow_guests", + "link" + ], + "properties": { + "id": { + "type": "string", + "description": "Unique room identifier." + }, + "name": { + "type": "string", + "description": "Room display name.", + "minLength": 2 + }, + "room_type": { + "$ref": "#/components/schemas/RoomType" + }, + "access_code": { + "nullable": true, + "type": "string", + "description": "9-digit numeric access code if enabled.", + "pattern": "^[0-9]{9}$" + }, + "allow_guests": { + "type": "boolean", + "description": "Whether guests can join the room." + }, + "link": { + "type": "string", + "format": "uri", + "description": "Absolute URL to open the room in the web UI." + } + } + }, + "CreateRoomRequest": { + "type": "object", + "description": "Payload to create a room.", + "required": ["name", "room_type"], + "properties": { + "name": { + "type": "string", + "description": "Room name.", + "minLength": 2, + "maxLength": 255 + }, + "room_type": { + "type": "integer", + "description": "Existing room type ID." + }, + "access_code": { + "nullable": true, + "type": "string", + "description": "9-digit numeric access code.\nDepending on room type settings, this field may be required, optional, or prohibited.\n", + "pattern": "^[0-9]{9}$" + }, + "allow_guests": { + "type": "boolean", + "description": "Guest access allowed for the room.\nDepending on room type settings, this fields value might be enforced (See room type attribute `has_allow_guests_enforced` and `has_allow_guests_default`)\n" + } + } + }, + "RoomSingleResponse": { + "type": "object", + "description": "Single room response wrapper.", + "required": ["data"], + "properties": { + "data": { + "$ref": "#/components/schemas/Room" + } + } + }, + "ErrorMessage": { + "type": "object", + "description": "Generic error message response.", + "required": ["message"], + "properties": { + "message": { + "type": "string", + "description": "Human-readable error description." + } + } + }, + "ValidationErrorResponse": { + "type": "object", + "description": "Validation error response.", + "required": ["message", "errors"], + "properties": { + "message": { + "type": "string", + "description": "High-level validation failure message." + }, + "errors": { + "type": "object", + "description": "Validation errors grouped by field name.", + "additionalProperties": { + "type": "array", + "items": { + "type": "string" + } + } + } + } + } + } + } +} diff --git a/docs/package.json b/docs/package.json index 12b9bfdc7..4db2001c4 100644 --- a/docs/package.json +++ b/docs/package.json @@ -4,12 +4,12 @@ "private": true, "scripts": { "docusaurus": "docusaurus", - "start": "docusaurus start", + "start": "docusaurus start --host 0.0.0.0", "build": "docusaurus build", "swizzle": "docusaurus swizzle", "deploy": "docusaurus deploy", "clear": "docusaurus clear", - "serve": "docusaurus serve", + "serve": "docusaurus serve --host 0.0.0.0", "write-translations": "docusaurus write-translations", "write-heading-ids": "docusaurus write-heading-ids" }, @@ -21,7 +21,8 @@ "clsx": "^2.0.0", "prism-react-renderer": "^2.3.0", "react": "^18.0.0", - "react-dom": "^18.0.0" + "react-dom": "^18.0.0", + "redocusaurus": "^2.5.0" }, "devDependencies": { "@docusaurus/module-type-aliases": "^3.7.0", diff --git a/docs/redocly.yaml b/docs/redocly.yaml new file mode 100644 index 000000000..e604065ce --- /dev/null +++ b/docs/redocly.yaml @@ -0,0 +1,13 @@ +# NOTE: Only supports options marked as "Supported in Redoc CE" +# See https://redocly.com/docs/cli/configuration/ for more information. + +extends: + - recommended + +rules: + no-unused-components: error + +theme: + # See https://redocly.com/docs/redoc/config/ + openapi: + disableSearch: true diff --git a/lang/en/auth.php b/lang/en/auth.php index 31daa49b2..0d96e5137 100644 --- a/lang/en/auth.php +++ b/lang/en/auth.php @@ -70,4 +70,22 @@ ], 'throttle' => 'Too many login attempts. Please try again in :seconds seconds.', 'throttle_email' => 'You have recently requested an email change. Please wait and try again later.', + 'tokens' => [ + 'active' => 'Connected Applications', + 'created_at' => 'Created at', + 'expires_at' => 'Expires at', + 'last_used_at' => 'Last used at', + 'never_used' => 'Never used', + 'revoke' => 'Revoke', + 'scopes' => 'Permissions', + 'nodata' => 'No connected applications', + ], + 'oauth' => [ + 'authorize_client' => ':client is requesting permission to access your account.', + 'scopes' => [ + 'user_own_read' => 'Access your profile information', + 'room_own_read' => 'Access your rooms', + 'room_create' => 'Create rooms', + ], + ], ]; diff --git a/resources/js/components/UserTabSecurity.vue b/resources/js/components/UserTabSecurity.vue index 886344cde..cc4807dc4 100644 --- a/resources/js/components/UserTabSecurity.vue +++ b/resources/js/components/UserTabSecurity.vue @@ -31,6 +31,13 @@ @busy="(state) => (isBusy = state)" /> + + + + diff --git a/resources/js/components/UserTabSecurityTokensSection.vue b/resources/js/components/UserTabSecurityTokensSection.vue new file mode 100644 index 000000000..f65860fb4 --- /dev/null +++ b/resources/js/components/UserTabSecurityTokensSection.vue @@ -0,0 +1,118 @@ + + + diff --git a/resources/js/router.js b/resources/js/router.js index 2f56c15fb..57bf2c4db 100644 --- a/resources/js/router.js +++ b/resources/js/router.js @@ -32,6 +32,7 @@ import { useToast } from "./composables/useToast"; import i18n from "./i18n"; import { useUserPermissions } from "./composables/useUserPermission.js"; import { useApi } from "./composables/useApi.js"; +import OAuthAuthorize from "./views/OAuthAuthorize.vue"; const Home = Object.values( import.meta.glob(["../custom/js/views/Home.vue", "./views/Home.vue"], { @@ -127,6 +128,12 @@ export const routes = [ guestsOnly: true, }, }, + { + path: "/oauth/authorize", + name: "oauth.authorize", + component: OAuthAuthorize, + meta: { requiresAuth: true }, + }, { path: "/rooms", name: "rooms.index", diff --git a/resources/js/services/Api.js b/resources/js/services/Api.js index b84a41d5b..8f6424c0f 100644 --- a/resources/js/services/Api.js +++ b/resources/js/services/Api.js @@ -109,7 +109,7 @@ export class Api { if (options.redirectOnUnauthenticated !== false) { this.router.replace({ name: "login", - query: { redirect: this.router.currentRoute.value.path }, + query: { redirect: this.router.currentRoute.value.fullPath }, }); } } diff --git a/resources/js/views/Login.vue b/resources/js/views/Login.vue index 6066e48d0..230b6e90a 100644 --- a/resources/js/views/Login.vue +++ b/resources/js/views/Login.vue @@ -176,6 +176,7 @@ async function handleLogin({ data, id }) { errors[id] = null; loading.value = true; await authStore.login(data, id); + toast.success(t("auth.flash.login")); // check if user should be redirected back after login if (route.query.redirect !== undefined) { diff --git a/resources/js/views/OAuthAuthorize.vue b/resources/js/views/OAuthAuthorize.vue new file mode 100644 index 000000000..63474d35e --- /dev/null +++ b/resources/js/views/OAuthAuthorize.vue @@ -0,0 +1,193 @@ + + + + + diff --git a/routes/api.php b/routes/api.php index d50bd2206..697315ad0 100644 --- a/routes/api.php +++ b/routes/api.php @@ -10,6 +10,8 @@ use App\Http\Controllers\api\v1\auth\VerificationController; use App\Http\Controllers\api\v1\LocaleController; use App\Http\Controllers\api\v1\MeetingController; +use App\Http\Controllers\api\v1\OAuthAuthorizationController; +use App\Http\Controllers\api\v1\OAuthTokenController; use App\Http\Controllers\api\v1\PermissionController; use App\Http\Controllers\api\v1\RecordingController; use App\Http\Controllers\api\v1\RoleController; @@ -26,7 +28,10 @@ use App\Http\Controllers\api\v1\SettingsController; use App\Http\Controllers\api\v1\StreamingController; use App\Http\Controllers\api\v1\UserController; +use App\Http\Middleware\ApiRedirectMiddleware; use Illuminate\Support\Facades\Route; +use Laravel\Passport\Http\Controllers\ApproveAuthorizationController; +use Laravel\Passport\Http\Controllers\DenyAuthorizationController; /* |-------------------------------------------------------------------------- @@ -159,6 +164,15 @@ Route::get('sessions', [SessionController::class, 'index'])->name('sessions.index'); Route::delete('sessions', [SessionController::class, 'destroy'])->name('sessions.destroy'); + Route::middleware(['enable_if_config:passport.enabled', ApiRedirectMiddleware::class])->group(function () { + Route::get('oauth/authorize', [OAuthAuthorizationController::class, 'authorize'])->middleware('cache.headers:no_store')->name('oauth.authorize'); + Route::post('oauth/authorize', [ApproveAuthorizationController::class, 'approve'])->middleware('cache.headers:no_store')->name('oauth.approve'); + Route::delete('oauth/authorize', [DenyAuthorizationController::class, 'deny'])->middleware('cache.headers:no_store')->name('oauth.deny'); + + Route::get('tokens', [OAuthTokenController::class, 'index'])->name('tokens.index'); + Route::delete('tokens/{token}', [OAuthTokenController::class, 'destroy'])->name('tokens.destroy'); + }); + Route::post('servers/check', [ServerController::class, 'check'])->name('servers.check')->middleware('can:viewAny,App\Models\Server'); Route::post('servers/{server}/panic', [ServerController::class, 'panic'])->name('servers.panic')->middleware('can:update,server'); Route::apiResource('servers', ServerController::class); diff --git a/routes/external_api.php b/routes/external_api.php new file mode 100644 index 000000000..2c8e287f0 --- /dev/null +++ b/routes/external_api.php @@ -0,0 +1,35 @@ +json([ + 'version' => '1', + 'enabled' => config('passport.enabled'), + ]); +}); + +Route::prefix('v1')->name('external_api.v1.')->middleware(['auth:oauth_external', 'enable_if_config:passport.enabled'])->group(function () { + Route::get('me', [CurrentTokenController::class, 'show']) + ->name('current-token.show') + ->middleware(CheckToken::using('user:own:read')); + + Route::get('room_types', [RoomTypeController::class, 'index']) + ->name('room_types.index') + ->middleware(CheckTokenForAnyScope::using('room:create', 'room:own:read')); + + Route::get('rooms', [RoomController::class, 'index']) + ->name('rooms.index') + ->middleware(CheckToken::using('room:own:read')); + + Route::post('rooms', [RoomController::class, 'store']) + ->name('rooms.store') + ->middleware(CheckToken::using('room:create')); +}); diff --git a/routes/web.php b/routes/web.php index 408da9e3c..ce4058115 100644 --- a/routes/web.php +++ b/routes/web.php @@ -11,6 +11,7 @@ use App\Http\Controllers\RecordingFormatController; use App\Http\Controllers\RoomFileController; use Illuminate\Support\Facades\Route; +use Laravel\Passport\Http\Controllers\AccessTokenController; use Spatie\Csp\AddCspHeaders; /* @@ -46,6 +47,10 @@ Route::post('auth/oidc/logout', [OIDCController::class, 'logout'])->name('auth.oidc.logout'); }); +Route::middleware('enable_if_config:passport.enabled')->group(function () { + Route::post('oauth/token', [AccessTokenController::class, 'issueToken'])->name('token'); +}); + if (config('greenlight.compatibility')) { Route::prefix(config('greenlight.base'))->group(function () { // Greenlight v2 room urls