Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
109 changes: 91 additions & 18 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`:
Expand Down Expand Up @@ -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:
Expand Down
5 changes: 5 additions & 0 deletions config/scim.php
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
144 changes: 104 additions & 40 deletions src/RouteProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -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');
});
}

Expand All @@ -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];
}
}
9 changes: 8 additions & 1 deletion src/ServiceProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}

Expand Down