https://github.com/sumer5020/laravel-keycloak-guard
A Keycloak Guard for Laravel — robsontenorio/laravel-keycloak-guard fork
https://github.com/sumer5020/laravel-keycloak-guard
Last synced: 4 months ago
JSON representation
A Keycloak Guard for Laravel — robsontenorio/laravel-keycloak-guard fork
- Host: GitHub
- URL: https://github.com/sumer5020/laravel-keycloak-guard
- Owner: sumer5020
- License: mit
- Created: 2026-03-23T19:07:00.000Z (4 months ago)
- Default Branch: main
- Last Pushed: 2026-03-23T21:23:57.000Z (4 months ago)
- Last Synced: 2026-03-24T19:54:23.428Z (4 months ago)
- Language: PHP
- Size: 39.1 KB
- Stars: 0
- Watchers: 0
- Forks: 0
- Open Issues: 0
-
Metadata Files:
- Readme: README.md
- License: LICENSE
Awesome Lists containing this project
README
# Laravel Keycloak Guard
**Note: This Version is not production ready yet, still in active development**
> **robsontenorio/laravel-keycloak-guard fork** — Full support for **Laravel 13**, **Keycloak 26+**, and the **Keycloak 26 Organizations** feature (`--features=organization`).
[](https://laravel.com)
[](https://www.keycloak.org)
[](https://php.net)
[](LICENSE)
---
## What's New VS the Original Package:
| Feature | Original | This Version |
|------------------------------------|----------|--------------|
| Laravel 13 support | ❌ | ✅ |
| Keycloak 26 JWKS auto-discovery | ❌ | ✅ |
| Automatic key rotation support | ❌ | ✅ |
| Keycloak 26 Organizations | ❌ | ✅ |
| `KeycloakOrg` facade | ❌ | ✅ |
| `keycloak.org` middleware | ❌ | ✅ |
| `keycloak.org.role` middleware | ❌ | ✅ |
| `HasOrganizations` trait | ❌ | ✅ |
| `TokenUser` (no-DB mode) | Partial | ✅ |
| `Auth::payload()` | ❌ | ✅ |
| `Auth::roles()` | ❌ | ✅ |
| `RealmResource` (API Resource) | ❌ | ✅ |
| `keycloak:doctor` command | ❌ | ✅ |
| `ActingAsKeycloak` (Testing Trait) | ❌ | ✅ |
| PHP 8.3 constructor promotion | ❌ | ✅ |
---
## Requirements
- PHP **8.3+**
- Laravel **12.x / 13.x**
- Keycloak **21+** (Organizations require **26+**)
---
## Installation
```bash
composer require sumer5020/laravel-keycloak-guard
```
Publish the config:
```bash
php artisan vendor:publish --tag=keycloak-config
```
Optionally publish migrations (only if using `organizations.sync_to_database`):
```bash
php artisan vendor:publish --tag=keycloak-migrations
php artisan migrate
```
---
## Health & Diagnostics
The package includes a "Doctor" command to help you troubleshoot connectivity and configuration issues.
```bash
php artisan keycloak:doctor
```
It performs the following checks:
- **Configuration**: Verifies that required environment variables are set.
- **Connectivity**: Attempts to fetch the OIDC discovery document (`.well-known/openid-configuration`) from Keycloak.
- **Organizations**: Checks if the organization feature is correctly configured.
---
## API Resources
If you need to expose your Keycloak configuration (realm name, base URL, issuer, JWKS URI) to your frontend (e.g., for `keycloak-js`), you can use the built-in `RealmResource`.
```php
use KeycloakGuard\Http\Resources\RealmResource;
Route::get('/keycloak/config', function () {
return new RealmResource([]);
});
```
This returns a standardized JSON response:
```json
{
"realm": "my-realm",
"base_url": "https://keycloak.example.com",
"jwks_uri": "https://keycloak.example.com/realms/my-realm/protocol/openid-connect/certs",
"issuer": "https://keycloak.example.com/realms/my-realm"
}
```
---
## Basic Setup
### 1. Configure the Guard
**`config/auth.php`**:
```php
'guards' => [
'api' => [
'driver' => 'keycloak',
'provider' => 'users',
],
],
'providers' => [
'users' => [
'driver' => 'eloquent',
'model' => App\Models\User::class,
],
],
```
### 2. Environment Variables
```env
# ── Required ────────────────────────────────────────────────
KEYCLOAK_BASE_URL=https://keycloak.example.com
KEYCLOAK_REALM=my-realm
KEYCLOAK_ALLOWED_RESOURCES=my-laravel-api
# ── Key / Token Verification ────────────────────────────────
# Option A: Static public key (from Realm Settings → Keys → RS256 → Public Key)
KEYCLOAK_REALM_PUBLIC_KEY=MIIBIjANBgkq...
# Option B: JWKS auto-discovery (recommended for Keycloak 26+, supports key rotation)
# Leave KEYCLOAK_REALM_PUBLIC_KEY empty — the package constructs the JWKS URI automatically
# Or set it explicitly:
KEYCLOAK_JWKS_URI=https://keycloak.example.com/realms/my-realm/protocol/openid-connect/certs
KEYCLOAK_JWKS_CACHE_TTL=3600
# ── User Matching ────────────────────────────────────────────
KEYCLOAK_TOKEN_PRINCIPAL_ATTRIBUTE=sub # JWT claim used as identifier
KEYCLOAK_USER_PROVIDER_CREDENTIAL=keycloak_id # DB column to match against
# ── Optional ─────────────────────────────────────────────────
KEYCLOAK_APPEND_DECODED_TOKEN=true # Attach $user->token (stdClass)
KEYCLOAK_LEEWAY=30 # Clock skew tolerance (seconds)
KEYCLOAK_LOAD_USER_FROM_DATABASE=true
KEYCLOAK_TOKEN_ENCRYPTION_ALGORITHM=RS256
# ── Organizations (Keycloak 26+) ─────────────────────────────
KEYCLOAK_ORGANIZATIONS_ENABLED=true
KEYCLOAK_ORGANIZATION_HEADER=X-Organization # Header for selecting active org
KEYCLOAK_ORGANIZATION_REQUIRE=false # 403 if no org in token?
KEYCLOAK_ORGANIZATION_SYNC_DB=false # Persist orgs to database?
```
### 3. Protect Routes
```php
// routes/api.php
// Basic auth
Route::middleware('auth:api')->group(function () {
Route::get('/me', fn(Request $r) => $r->user());
});
// Role-based access
Route::middleware(['auth:api', 'keycloak.role:admin'])->group(function () {
Route::get('/admin', AdminController::class);
});
// Organization-scoped routes (Keycloak 26+)
Route::middleware(['auth:api', 'keycloak.org'])->group(function () {
Route::apiResource('projects', ProjectController::class);
// Org admin only
Route::middleware('keycloak.org.role:admin')->group(function () {
Route::apiResource('members', MemberController::class);
});
});
```
---
## Keycloak 26 Organizations
### How It Works
When Keycloak is started with `--features=organization`, it injects an `organization` claim into the JWT:
```json
{
"sub": "f7a8b9c0-...",
"preferred_username": "john@acme.com",
"organization": {
"acme-corp": {
"id": "3fa85f64-...",
"name": "Acme Corporation",
"roles": ["admin", "member"]
}
}
}
```
For users in **multiple organizations**, the client sends an `X-Organization` header to select the active one.
### The `KeycloakOrg` Facade
```php
use KeycloakGuard\Facades\KeycloakOrg;
// Get the active organization for this request
$org = KeycloakOrg::current();
// => ['alias' => 'acme-corp', 'id' => '3fa85f64-...', 'name' => 'Acme Corporation', 'roles' => ['admin']]
// Get all organizations the user belongs to (from the token)
$orgs = KeycloakOrg::all();
// Check membership
KeycloakOrg::belongsTo('acme-corp'); // bool
KeycloakOrg::hasOrganizations(); // bool
// Role checks within the active organization
KeycloakOrg::hasRole('admin'); // bool
KeycloakOrg::hasRole('admin', 'acme-corp'); // bool (explicit org)
KeycloakOrg::hasAnyRole(['admin', 'billing-manager']); // bool
// Sync to DB (requires sync_to_database = true)
KeycloakOrg::syncToDatabase(auth()->user());
```
### `HasOrganizations` Trait
Add to your `User` model for database relationships and convenient helpers:
```php
use KeycloakGuard\Traits\HasOrganizations;
class User extends Authenticatable
{
use HasOrganizations;
}
```
Then use:
```php
// From JWT (no DB query)
$user->currentOrganization(); // array|null
$user->hasOrgRole('admin'); // bool
$user->belongsToOrg('acme-corp'); // bool
// From database (requires sync_to_database)
$user->organizations()->get();
$user->organizations()->where('alias', 'acme-corp')->first();
```
### Using Organization Context in Controllers
```php
class ProjectController extends Controller
{
public function index(): JsonResponse
{
// Array from JWT — no DB query
$org = KeycloakOrg::current();
// Eloquent model — only if sync_to_database = true
$orgModel = app('current_organization_model');
return response()->json(
Project::where('organization_id', $orgModel->id)->paginate()
);
}
public function store(StoreProjectRequest $request): JsonResponse
{
$org = app('current_organization_model');
$project = Project::create([
...$request->validated(),
'organization_id' => $org->id,
]);
return response()->json($project, 201);
}
}
```
---
## Auth Guard Methods
```php
use Illuminate\Support\Facades\Auth;
// Standard Laravel
Auth::user(); // Authenticatable|null
Auth::check(); // bool
Auth::id(); // mixed
// Keycloak-specific
Auth::token(); // string|null — full decoded token as JSON
Auth::payload(); // stdClass|null — decoded token object
Auth::roles(); // array — all realm + resource roles (with inheritance)
Auth::roles('my-api'); // array — resource-specific roles (includes realm roles)
Auth::hasRole('admin'); // bool
Auth::hasRole('editor', 'my-api'); // bool — resource-scoped
// Organizations
Auth::organizations(); // OrganizationService
```
---
## Middleware Reference
| Middleware | Description | Example |
|--------------------------------|---------------------------------------|----------------------------------------------|
| `keycloak.role:admin` | Requires realm or any resource role | `middleware('keycloak.role:admin,editor')` |
| `keycloak.role:editor\|my-api` | Requires role in specific resource | `middleware('keycloak.role:editor\|my-api')` |
| `keycloak.org` | Resolves active org from JWT + header | `middleware('keycloak.org')` |
| `keycloak.org.role:admin` | Requires org-level role | `middleware('keycloak.org.role:admin')` |
> **Note**: The `keycloak.role` middleware supports role inheritance. If a user has a realm-level role (e.g., `admin`), they will pass checks for any resource-specific role requirements.
---
## No-Database Mode
If your API doesn't have a `users` table, set:
```env
KEYCLOAK_LOAD_USER_FROM_DATABASE=false
KEYCLOAK_APPEND_DECODED_TOKEN=true
```
`Auth::user()` returns a `TokenUser` built from JWT claims:
```php
$user = Auth::user();
$user->sub; // UUID
$user->preferred_username; // username
$user->email; // email
$user->token; // full stdClass of JWT claims
```
---
## Custom User Provider
```php
// app/Providers/CustomUserProvider.php
class CustomUserProvider implements UserProvider
{
public function customRetrieveUser(stdClass $token, array $credentials): ?User
{
return User::updateOrCreate(
['keycloak_id' => $token->sub],
[
'name' => $token->name ?? $token->preferred_username,
'email' => $token->email,
]
);
}
// ... other required methods
}
```
```env
KEYCLOAK_USER_PROVIDER_CUSTOM_RETRIEVE_METHOD=customRetrieveUser
```
---
## Keycloak 26 — Admin Configuration Checklist
In your Keycloak 26 admin console:
**Realm Settings → Keys → RS256**: Copy the public key for `KEYCLOAK_REALM_PUBLIC_KEY`, or leave blank to use JWKS auto-discovery.
**Client Settings** (for your Laravel API client):
- Client protocol: `openid-connect`
- Access type: `confidential`
- Standard flow: **OFF** (API only)
- Direct access grants: **OFF**
- Service accounts enabled: **ON** (for M2M)
**Organizations** (Keycloak 26+):
- Realm Settings → General → Enable Organizations: **ON**
- OR start Keycloak with `--features=organization`
- Add the `organization` mapper to your client's token scope
**Token Mappers** — ensure your client has:
- `organization` claim mapper (type: Organization Membership)
- `realm roles` mapper
- `client roles` mapper
---
## Testing
The package provides a powerful `ActingAsKeycloak` trait to mock authenticated states in your feature tests without needing a live Keycloak server.
```php
namespace Tests\Feature;
use KeycloakGuard\Testing\ActingAsKeycloak;
use Tests\TestCase;
class ExampleTest extends TestCase
{
use ActingAsKeycloak;
public function test_api_is_protected(): void
{
// Simple authentication
$this->withKeycloakToken(['name' => 'John Doe'])
->getJson('/api/me')
->assertOk();
// Authentication with specific roles
$this->withKeycloakToken([
'realm_access' => ['roles' => ['admin']]
])->getJson('/api/admin')->assertOk();
}
public function test_organization_access(): void
{
// Authenticate with organization membership
$this->withKeycloakOrganization([
'acme' => ['id' => 'org-1', 'name' => 'Acme Corp', 'roles' => ['member']]
])
->withOrganization('acme') // Select active org via header
->getJson('/api/projects')
->assertOk();
}
}
```
---
## License
[MIT](LICENSE)