From fb4d05c0588b3754cd4dd880beac843270f5c0fe Mon Sep 17 00:00:00 2001 From: Arie Timmerman Date: Thu, 16 Oct 2025 19:19:58 +0000 Subject: [PATCH] Enhance SCIM route configuration options and improve route registration logic. Allow easier router customisation Fixes #140 --- README.md | 109 +++++++++++++++++++++++++----- config/scim.php | 5 ++ src/RouteProvider.php | 144 +++++++++++++++++++++++++++++----------- src/ServiceProvider.php | 9 ++- 4 files changed, 208 insertions(+), 59 deletions(-) diff --git a/README.md b/README.md index fdc16af..ce50699 100644 --- a/README.md +++ b/README.md @@ -77,6 +77,54 @@ Optional "Me" routes can be enabled separately: ## Configuration +### Route Configuration + +The SCIM server routes can be customized using the following configuration options in `config/scim.php`: + +```php +return [ + // Base path for all SCIM routes + 'path' => env('SCIM_BASE_PATH', '/scim'), + + // Optional domain to restrict SCIM endpoints to + 'domain' => env('SCIM_DOMAIN', null), + + // Middleware for protected routes (resource operations) + 'middleware' => env('SCIM_MIDDLEWARE', []), + + // Middleware for public routes (ServiceProviderConfig, Schemas, ResourceTypes) + 'public_middleware' => env('SCIM_PUBLIC_MIDDLEWARE', []), +]; +``` + +You can override these in your `.env` file: + +``` +SCIM_BASE_PATH=/scim/api +SCIM_MIDDLEWARE=api,auth:sanctum +SCIM_PUBLIC_MIDDLEWARE=api +``` + +If you need more control, you can disable route auto-publishing and register the routes manually: + +```php +// config/scim.php +return [ + 'publish_routes' => false, + // other config... +]; + +// In your RouteServiceProvider or a custom service provider: +ArieTimmerman\Laravel\SCIMServer\RouteProvider::routes([ + 'path' => '/custom-scim', + 'domain' => 'api.example.com', + 'middleware' => ['api', 'auth:api', 'scoped-tokens'], + 'public_middleware' => ['api', 'rate-limit'], +]); +``` + +### Resource Configuration + The package resolves configuration via `SCIMConfig::class`. Extend it to tweak resource definitions, attribute mappings, filters, or pagination defaults. Register your custom config in `app/Providers/AppServiceProvider.php`: @@ -120,30 +168,55 @@ Cursor-based pagination is enabled by default via the [SCIM cursor pagination dr ## Security & app integration SCIM grants the ability to view, add, update, and delete users or groups. Make sure you secure the routes before shipping to production. -1. Disable automatic route publishing if you plan to wrap routes in your own middleware: +You have two approaches to securing your SCIM endpoints: - ```php - // config/scim.php - return [ - 'publish_routes' => false, - ]; - ``` +### Option 1: Configure middleware via config +The simplest approach is to set middleware in your config: -2. Re-register the routes with your preferred middleware stack: +```php +// config/scim.php +return [ + 'middleware' => ['api', 'auth:sanctum'], // For protected resource routes + 'public_middleware' => ['api'], // For schema/discovery endpoints +]; +``` - ```php - use ArieTimmerman\Laravel\SCIMServer\RouteProvider as SCIMServerRouteProvider; +Or via environment variables: +``` +SCIM_MIDDLEWARE=api,auth:sanctum +SCIM_PUBLIC_MIDDLEWARE=api +``` + +### Option 2: Manual route registration +For more control, disable automatic route publishing: - SCIMServerRouteProvider::publicRoutes(); +```php +// config/scim.php +return [ + 'publish_routes' => false, +]; +``` - Route::middleware('auth:api')->group(function () { - SCIMServerRouteProvider::routes([ - 'public_routes' => false, - ]); +Then re-register the routes with your preferred middleware and configuration: - SCIMServerRouteProvider::meRoutes(); - }); - ``` +```php +use ArieTimmerman\Laravel\SCIMServer\RouteProvider as SCIMServerRouteProvider; + +SCIMServerRouteProvider::publicRoutes([ + 'path' => '/scim', + 'middleware' => ['api'], +]); + +Route::middleware('auth:api')->group(function () { + SCIMServerRouteProvider::routes([ + 'path' => '/scim', + 'middleware' => ['custom-scim-check'], + 'public_routes' => false, + ]); + + SCIMServerRouteProvider::meRoutes(); +}); +``` ## Test server Bring up the full demo stack with Docker Compose: diff --git a/config/scim.php b/config/scim.php index de49f62..d3cf58d 100644 --- a/config/scim.php +++ b/config/scim.php @@ -5,6 +5,11 @@ 'omit_main_schema_in_return' => false, 'omit_null_values' => true, + 'path' => env('SCIM_BASE_PATH', '/scim'), + 'domain' => env('SCIM_DOMAIN', null), + 'middleware' => env('SCIM_MIDDLEWARE', []), + 'public_middleware' => env('SCIM_PUBLIC_MIDDLEWARE', []), + 'pagination' => [ 'defaultPageSize' => 10, 'maxPageSize' => 100, diff --git a/src/RouteProvider.php b/src/RouteProvider.php index 26f8442..f2282b6 100644 --- a/src/RouteProvider.php +++ b/src/RouteProvider.php @@ -3,69 +3,84 @@ namespace ArieTimmerman\Laravel\SCIMServer; use ArieTimmerman\Laravel\SCIMServer\Middleware\SCIMHeaders; -use Illuminate\Support\Facades\Route; use Illuminate\Routing\Middleware\SubstituteBindings; +use Illuminate\Support\Facades\Route; /** - * Helper class for the URL shortener + * Registers SCIM routes with configurable prefix, domain and middleware. */ class RouteProvider { - protected static $prefix = 'scim'; public static function routes(array $options = []) { - + $config = static::getRoutingConfig($options); + if (!isset($options['public_routes']) || $options['public_routes'] === true) { - static::publicRoutes($options); + static::publicRoutes([ + 'prefix' => $config['prefix'], + 'middleware' => $config['public_middleware'], + 'domain' => $config['domain'], + ]); } - Route::prefix(static::$prefix)->group( - function () use ($options) { - Route::prefix('v2')->middleware([ - SubstituteBindings::class, - SCIMHeaders::class, - ])->group( - function () use ($options) { - static::allRoutes($options); - } - ); - - Route::get('v1', [\ArieTimmerman\Laravel\SCIMServer\Http\Controllers\ResourceController::class, 'wrongVersion']); - Route::prefix('v1')->group( - function () { - Route::fallback([\ArieTimmerman\Laravel\SCIMServer\Http\Controllers\ResourceController::class, 'wrongVersion']); - } - ); - } - ); + $group = static::buildRouteGroup($config, 'middleware'); + + Route::group($group, function () use ($options) { + Route::prefix('v2')->middleware([ + SubstituteBindings::class, + SCIMHeaders::class, + ])->group(function () use ($options) { + static::allRoutes($options); + }); + + Route::get('v1', [\ArieTimmerman\Laravel\SCIMServer\Http\Controllers\ResourceController::class, 'wrongVersion']); + Route::prefix('v1')->group(function () { + Route::fallback([\ArieTimmerman\Laravel\SCIMServer\Http\Controllers\ResourceController::class, 'wrongVersion']); + }); + }); } public static function meRoutes(array $options = []) { - Route::prefix(static::$prefix)->group(function () { - Route::get("/v2/Me", [\ArieTimmerman\Laravel\SCIMServer\Http\Controllers\MeController::class, 'getMe'])->name('scim.me.get'); + $config = static::getRoutingConfig($options); + $group = static::buildRouteGroup($config, 'middleware'); + + Route::group($group, function () { + Route::get('/v2/Me', [\ArieTimmerman\Laravel\SCIMServer\Http\Controllers\MeController::class, 'getMe'])->name('scim.me.get'); Route::put('/v2/Me', [\ArieTimmerman\Laravel\SCIMServer\Http\Controllers\MeController::class, 'replaceMe'])->name('scim.me.put'); }); } public static function meRoutePost(array $options = []) { - Route::prefix(static::$prefix)->group(function () { + $config = static::getRoutingConfig($options); + $group = static::buildRouteGroup($config, 'middleware'); + + Route::group($group, function () { Route::post('/v2/Me', [\ArieTimmerman\Laravel\SCIMServer\Http\Controllers\MeController::class, 'createMe'])->name('scim.me.post'); }); } public static function publicRoutes(array $options = []) { - Route::prefix(static::$prefix)->group(function () { - Route::get("/v2/ServiceProviderConfig", [\ArieTimmerman\Laravel\SCIMServer\Http\Controllers\ServiceProviderController::class, 'index'])->name('scim.serviceproviderconfig'); + if (isset($options['prefix']) && isset($options['middleware'])) { + $config = $options; + } else { + $config = static::getRoutingConfig($options); + $config['middleware'] = $config['public_middleware']; + } + + $group = static::buildRouteGroup($config, 'middleware'); - Route::get("/v2/Schemas", [\ArieTimmerman\Laravel\SCIMServer\Http\Controllers\SchemaController::class, 'index']); - Route::get("/v2/Schemas/{id}", [\ArieTimmerman\Laravel\SCIMServer\Http\Controllers\SchemaController::class, 'show'])->name('scim.schemas'); + Route::group($group, function () { + Route::get('/v2/ServiceProviderConfig', [\ArieTimmerman\Laravel\SCIMServer\Http\Controllers\ServiceProviderController::class, 'index'])->name('scim.serviceproviderconfig'); - Route::get("/v2/ResourceTypes", [\ArieTimmerman\Laravel\SCIMServer\Http\Controllers\ResourceTypesController::class, 'index']); - Route::get("/v2/ResourceTypes/{id}", [\ArieTimmerman\Laravel\SCIMServer\Http\Controllers\ResourceTypesController::class, 'show'])->name('scim.resourcetype'); + Route::get('/v2/Schemas', [\ArieTimmerman\Laravel\SCIMServer\Http\Controllers\SchemaController::class, 'index']); + Route::get('/v2/Schemas/{id}', [\ArieTimmerman\Laravel\SCIMServer\Http\Controllers\SchemaController::class, 'show'])->name('scim.schemas'); + + Route::get('/v2/ResourceTypes', [\ArieTimmerman\Laravel\SCIMServer\Http\Controllers\ResourceTypesController::class, 'index']); + Route::get('/v2/ResourceTypes/{id}', [\ArieTimmerman\Laravel\SCIMServer\Http\Controllers\ResourceTypesController::class, 'show'])->name('scim.resourcetype'); }); } @@ -75,18 +90,67 @@ private static function allRoutes(array $options = []) Route::get('', [\ArieTimmerman\Laravel\SCIMServer\Http\Controllers\ResourceController::class, 'crossResourceIndex']); Route::post('.search', [\ArieTimmerman\Laravel\SCIMServer\Http\Controllers\ResourceController::class, 'crossResourceSearch']); - Route::post("/Bulk", [\ArieTimmerman\Laravel\SCIMServer\Http\Controllers\BulkController::class, 'processBulkRequest']); + Route::post('/Bulk', [\ArieTimmerman\Laravel\SCIMServer\Http\Controllers\BulkController::class, 'processBulkRequest']); // TODO: Use the attributes parameters ?attributes=userName, excludedAttributes=asdg,asdg (respect "returned" settings "always") Route::get('/{resourceType}/{resourceObject}', [\ArieTimmerman\Laravel\SCIMServer\Http\Controllers\ResourceController::class, 'show'])->name('scim.resource'); - Route::get("/{resourceType}", [\ArieTimmerman\Laravel\SCIMServer\Http\Controllers\ResourceController::class, 'index'])->name('scim.resources'); - Route::post("/{resourceType}/.search", [\ArieTimmerman\Laravel\SCIMServer\Http\Controllers\ResourceController::class, 'search']); - Route::post("/{resourceType}", [\ArieTimmerman\Laravel\SCIMServer\Http\Controllers\ResourceController::class, 'create']); + Route::get('/{resourceType}', [\ArieTimmerman\Laravel\SCIMServer\Http\Controllers\ResourceController::class, 'index'])->name('scim.resources'); + Route::post('/{resourceType}/.search', [\ArieTimmerman\Laravel\SCIMServer\Http\Controllers\ResourceController::class, 'search']); + Route::post('/{resourceType}', [\ArieTimmerman\Laravel\SCIMServer\Http\Controllers\ResourceController::class, 'create']); - Route::put("/{resourceType}/{resourceObject}", [\ArieTimmerman\Laravel\SCIMServer\Http\Controllers\ResourceController::class, 'replace']); - Route::patch("/{resourceType}/{resourceObject}", [\ArieTimmerman\Laravel\SCIMServer\Http\Controllers\ResourceController::class, 'update']); - Route::delete("/{resourceType}/{resourceObject}", [\ArieTimmerman\Laravel\SCIMServer\Http\Controllers\ResourceController::class, 'delete']); + Route::put('/{resourceType}/{resourceObject}', [\ArieTimmerman\Laravel\SCIMServer\Http\Controllers\ResourceController::class, 'replace']); + Route::patch('/{resourceType}/{resourceObject}', [\ArieTimmerman\Laravel\SCIMServer\Http\Controllers\ResourceController::class, 'update']); + Route::delete('/{resourceType}/{resourceObject}', [\ArieTimmerman\Laravel\SCIMServer\Http\Controllers\ResourceController::class, 'delete']); Route::fallback([\ArieTimmerman\Laravel\SCIMServer\Http\Controllers\ResourceController::class, 'notImplemented']); } + + private static function getRoutingConfig(array $options = []): array + { + $path = $options['path'] ?? config('scim.path', '/scim'); + $domain = $options['domain'] ?? config('scim.domain'); + $protectedMiddleware = static::normalizeMiddleware($options['middleware'] ?? config('scim.middleware', [])); + $publicMiddleware = static::normalizeMiddleware($options['public_middleware'] ?? config('scim.public_middleware', [])); + + $prefix = trim($path, '/'); + if (empty($prefix)) { + $prefix = 'scim'; + } + + return [ + 'prefix' => $prefix, + 'domain' => $domain, + 'middleware' => $protectedMiddleware, + 'public_middleware' => $publicMiddleware, + ]; + } + + private static function buildRouteGroup(array $config, string $middlewareKey = 'middleware'): array + { + $group = ['prefix' => $config['prefix']]; + + $middleware = $config[$middlewareKey] ?? []; + if (!empty($middleware)) { + $group['middleware'] = $middleware; + } + + if (!empty($config['domain'])) { + $group['domain'] = $config['domain']; + } + + return $group; + } + + private static function normalizeMiddleware($middleware): array + { + if (empty($middleware)) { + return []; + } + + if (is_string($middleware)) { + $middleware = array_filter(array_map('trim', explode(',', $middleware))); + } + + return is_array($middleware) ? $middleware : [$middleware]; + } } diff --git a/src/ServiceProvider.php b/src/ServiceProvider.php index 88aaceb..a2143d7 100644 --- a/src/ServiceProvider.php +++ b/src/ServiceProvider.php @@ -68,7 +68,14 @@ function ($id, $route) { $router->middleware('SCIMHeaders', 'ArieTimmerman\Laravel\SCIMServer\Middleware\SCIMHeaders'); if (config('scim.publish_routes')) { - \ArieTimmerman\Laravel\SCIMServer\RouteProvider::routes(); + $routeOptions = [ + 'path' => config('scim.path', '/scim'), + 'domain' => config('scim.domain'), + 'middleware' => config('scim.middleware', []), + 'public_middleware' => config('scim.public_middleware', []), + ]; + + \ArieTimmerman\Laravel\SCIMServer\RouteProvider::routes($routeOptions); } }