Keycloak API Endpoints
How to get acesstoken using api endpoints curl
Laravel code:to get access token in 3 ways
Complete authentication laravel code
Token-based SSO (Authorization header, NO cookies)
Token Introspection (check “active” server-side) using api endpoints curl
Userinfo (standard OIDC profile endpoint)
Logout (end session)
JWKS / Certs (public keys for JWT verification)
OpenID Provider configuration (discovery)
Authorization endpoint (browser login)
Token Exchange (if enabled)
Get admin token (for Admin API calls)
Users(list,search and create user and set password)
List and get clients
realm and client roles
Realm groups
Sessions / Logout user sessions (admin)
“Which JWT is used where?” quick mapping
Laravel code to get all api endpoints
Why this Laravel JWT verification code exists
When you NEED this Laravel JWT verification code
JWT verification (decode + verify signature via JWKS)
Keycloak
KC_BASE=http://localhost:8080
REALM=master
Endpoints
Token: POST $KC_BASE/realms/$REALM/protocol/openid-connect/token
Introspect: POST $KC_BASE/realms/$REALM/protocol/openid-connect/token/introspect
Userinfo: GET $KC_BASE/realms/$REALM/protocol/openid-connect/userinfo
Logout: GET/POST $KC_BASE/realms/$REALM/protocol/openid-connect/logout
Admin: ... $KC_BASE/admin/realms/$REALM/...
How to get acesstoken using api endpoints curl
Token (get tokens)
tokenByPassword
POST $KC_BASE/realms/$REALM/protocol/openid-connect/token
Password grant (direct login)
curl -s -X POST "$KC_BASE/realms/$REALM/protocol/openid-connect/token" \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "grant_type=password" \
-d "client_id=$CLIENT_ID" \
-d "client_secret=$CLIENT_SECRET" \
-d "username=$USERNAME" \
-d "password=$PASSWORD"
client_credentials (service account)
tokenByClientCredentials
curl -s -X POST "$KC_BASE/realms/$REALM/protocol/openid-connect/token" \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "grant_type=client_credentials" \
-d "client_id=$CLIENT_ID" \
-d "client_secret=$CLIENT_SECRET"
refresh_token
tokenByRefreshToken
curl -s -X POST "$KC_BASE/realms/$REALM/protocol/openid-connect/token" \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "grant_type=refresh_token" \
-d "client_id=$CLIENT_ID" \
-d "client_secret=$CLIENT_SECRET" \
-d "refresh_token=$REFRESH_TOKEN"
JWT mapping
Response has: access_token (JWT), refresh_token (JWT), sometimes id_token (JWT, if OIDC login flow / scope includes openid).
In access_token you’ll commonly inspect:
iss, aud, azp, exp, iat, sub, scope, realm_access.roles, resource_access.{client}.roles.
https://chatgpt.com/c/693e3b14-8108-8321-b98b-ff1366d9fecc
Laravel code:to get access token in 3 ways
tokenByPassword
tokenByClientCredentials
tokenByRefreshToken
Laravel: Config you should keep in one place
config/services.php
'keycloak' => [
'base_url' => env('KEYCLOAK_BASE_URL', 'https://auth.example.com'),
'realm' => env('KEYCLOAK_REALM', 'master'),
'client_id' => env('KEYCLOAK_CLIENT_ID'),
'client_secret' => env('KEYCLOAK_CLIENT_SECRET'), // keep null for public clients
],
Laravel code: Token endpoint for each grant type
Create a service: app/Services/KeycloakOidcService.php
<?php
namespace App\Services;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
class KeycloakOidcService
{
private string $base;
private string $realm;
private string $clientId;
private ?string $clientSecret;
public function __construct()
{
$this->base = rtrim(config('services.keycloak.base_url'), '/');
$this->realm = config('services.keycloak.realm');
$this->clientId = config('services.keycloak.client_id');
$this->clientSecret = config('services.keycloak.client_secret');
}
private function tokenUrl(): string
{
return "{$this->base}/realms/{$this->realm}/protocol/openid-connect/token";
}
private function commonForm(): array
{
$form = ['client_id' => $this->clientId];
// If confidential client, send secret
if (!empty($this->clientSecret)) {
$form['client_secret'] = $this->clientSecret;
}
return $form;
}
/**
* A) Password Grant (direct login)
*/
public function tokenByPassword(string $username, string $password, ?string $scope = null): array
{
$payload = array_merge($this->commonForm(), [
'grant_type' => 'password',
'username' => $username,
'password' => $password,
]);
if ($scope) $payload['scope'] = $scope;
$res = Http::asForm()->post($this->tokenUrl(), $payload);
if (!$res->successful()) {
Log::warning('[KC] password token failed', ['status' => $res->status(), 'body' => $res->json()]);
return ['ok' => false, 'status' => $res->status(), 'error' => $res->json()];
}
return ['ok' => true, 'data' => $res->json()];
}
/**
* B) Client Credentials (service account)
*/
public function tokenByClientCredentials(?string $scope = null): array
{
$payload = array_merge($this->commonForm(), [
'grant_type' => 'client_credentials',
]);
if ($scope) $payload['scope'] = $scope;
$res = Http::asForm()->post($this->tokenUrl(), $payload);
if (!$res->successful()) {
Log::warning('[KC] client_credentials token failed', ['status' => $res->status(), 'body' => $res->json()]);
return ['ok' => false, 'status' => $res->status(), 'error' => $res->json()];
}
return ['ok' => true, 'data' => $res->json()];
}
/**
* C) Refresh Token
*/
public function tokenByRefreshToken(string $refreshToken, ?string $scope = null): array
{
$payload = array_merge($this->commonForm(), [
'grant_type' => 'refresh_token',
'refresh_token' => $refreshToken,
]);
if ($scope) $payload['scope'] = $scope;
$res = Http::asForm()->post($this->tokenUrl(), $payload);
if (!$res->successful()) {
Log::warning('[KC] refresh_token failed', ['status' => $res->status(), 'body' => $res->json()]);
return ['ok' => false, 'status' => $res->status(), 'error' => $res->json()];
}
return ['ok' => true, 'data' => $res->json()];
}
}
Example Controller usage
public function loginWithPassword(Request $request, \App\Services\KeycloakOidcService $kc)
{
$result = $kc->tokenByPassword($request->username, $request->password);
if (!$result['ok']) {
return response()->json(['status' => false, 'kc' => $result], 401);
}
return response()->json(['status' => true, 'tokens' => $result['data']]);
}
Complete authentication laravel code
)
.env
KEYCLOAK_BASE_URL=https://auth.motoshare.in
KEYCLOAK_REALM=motoshare
KEYCLOAK_CLIENT_ID=motoshare
KEYCLOAK_CLIENT_SECRET=xxxxxx # keep empty if public client
config/services.php
'keycloak' => [
'base_url' => env('KEYCLOAK_BASE_URL'),
'realm' => env('KEYCLOAK_REALM', 'master'),
'client_id' => env('KEYCLOAK_CLIENT_ID'),
'client_secret' => env('KEYCLOAK_CLIENT_SECRET'), // null/empty for public
],
Token Service (all 3 methods + userinfo)
app/Services/KeycloakOidcService.php
<?php
namespace App\Services;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
class KeycloakOidcService
{
private string $base;
private string $realm;
private string $clientId;
private ?string $clientSecret;
public function __construct()
{
$this->base = rtrim(config('services.keycloak.base_url'), '/');
$this->realm = config('services.keycloak.realm');
$this->clientId = config('services.keycloak.client_id');
$this->clientSecret = config('services.keycloak.client_secret') ?: null;
}
private function tokenUrl(): string
{
return "{$this->base}/realms/{$this->realm}/protocol/openid-connect/token";
}
private function userinfoUrl(): string
{
return "{$this->base}/realms/{$this->realm}/protocol/openid-connect/userinfo";
}
private function commonForm(): array
{
$form = ['client_id' => $this->clientId];
if (!empty($this->clientSecret)) {
$form['client_secret'] = $this->clientSecret;
}
return $form;
}
/** 1) FIRST LOGIN ONLY */
public function tokenByPassword(string $username, string $password, ?string $scope = null): array
{
$payload = array_merge($this->commonForm(), [
'grant_type' => 'password',
'username' => $username,
'password' => $password,
]);
if ($scope) $payload['scope'] = $scope;
$res = Http::asForm()->post($this->tokenUrl(), $payload);
if (!$res->successful()) {
Log::warning('[KC] password token failed', ['status' => $res->status(), 'body' => $res->json()]);
return ['ok' => false, 'status' => $res->status(), 'error' => $res->json()];
}
return ['ok' => true, 'data' => $res->json()];
}
/** 2) SERVER-TO-SERVER (NO USER) */
public function tokenByClientCredentials(?string $scope = null): array
{
$payload = array_merge($this->commonForm(), [
'grant_type' => 'client_credentials',
]);
if ($scope) $payload['scope'] = $scope;
$res = Http::asForm()->post($this->tokenUrl(), $payload);
if (!$res->successful()) {
Log::warning('[KC] client_credentials token failed', ['status' => $res->status(), 'body' => $res->json()]);
return ['ok' => false, 'status' => $res->status(), 'error' => $res->json()];
}
return ['ok' => true, 'data' => $res->json()];
}
/** 3) ACCESS TOKEN EXPIRED → USE REFRESH TOKEN */
public function tokenByRefreshToken(string $refreshToken, ?string $scope = null): array
{
$payload = array_merge($this->commonForm(), [
'grant_type' => 'refresh_token',
'refresh_token' => $refreshToken,
]);
if ($scope) $payload['scope'] = $scope;
$res = Http::asForm()->post($this->tokenUrl(), $payload);
if (!$res->successful()) {
Log::warning('[KC] refresh_token failed', ['status' => $res->status(), 'body' => $res->json()]);
return ['ok' => false, 'status' => $res->status(), 'error' => $res->json()];
}
return ['ok' => true, 'data' => $res->json()];
}
/** Standard OIDC profile endpoint */
public function userinfo(string $accessToken): array
{
$res = Http::withToken($accessToken)->get($this->userinfoUrl());
if (!$res->successful()) {
Log::warning('[KC] userinfo failed', ['status' => $res->status(), 'body' => $res->json()]);
return ['ok' => false, 'status' => $res->status(), 'error' => $res->json()];
}
return ['ok' => true, 'data' => $res->json()];
}
}
Token Storage + Auto Refresh Helper
app/Services/KeycloakTokenStore.php
<?php
namespace App\Services;
use Illuminate\Support\Facades\Log;
class KeycloakTokenStore
{
// store in session (simple). You can store in DB for mobile apps.
public function saveTokens(array $tokens): void
{
// tokens: access_token, refresh_token, expires_in, refresh_expires_in, token_type
session([
'kc_access_token' => $tokens['access_token'] ?? null,
'kc_refresh_token' => $tokens['refresh_token'] ?? null,
'kc_expires_at' => time() + (int)($tokens['expires_in'] ?? 0),
]);
}
public function clear(): void
{
session()->forget([
'kc_access_token',
'kc_refresh_token',
'kc_id_token',
'kc_expires_at',
]);
session()->invalidate();
session()->regenerateToken();
}
public function accessToken(): ?string
{
return session('kc_access_token');
}
public function refreshToken(): ?string
{
return session('kc_refresh_token');
}
public function expiresAt(): int
{
return (int) session('kc_expires_at', 0);
}
/**
* THIS decides WHEN to call tokenByRefreshToken()
*/
public function getValidAccessToken(KeycloakOidcService $kc): ?string
{
$access = $this->accessToken();
$refresh = $this->refreshToken();
$expAt = $this->expiresAt();
// if token exists and will not expire in next 30 seconds, use it
if ($access && time() < ($expAt - 30)) {
return $access;
}
// otherwise refresh if possible
if ($refresh) {
$res = $kc->tokenByRefreshToken($refresh);
if ($res['ok']) {
$data = $res['data'];
// Some KC configs rotate refresh_token; if not, keep old one
if (empty($data['refresh_token'])) {
$data['refresh_token'] = $refresh;
}
$this->saveTokens($data);
return $data['access_token'] ?? null;
}
Log::warning('[KC] refresh failed; need login again', $res);
}
return null; // force re-login
}
}
Controller showing EXACT use cases
app/Http/Controllers/KeycloakAuthController.php
<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
use App\Services\KeycloakOidcService;
use App\Services\KeycloakTokenStore;
class KeycloakAuthController extends Controller
{
/**
* ✅ When to use tokenByPassword():
* - user submits username/password FIRST TIME
*/
public function loginWithPassword(Request $request, KeycloakOidcService $kc, KeycloakTokenStore $store)
{
$request->validate([
'username' => 'required|string',
'password' => 'required|string',
]);
$result = $kc->tokenByPassword($request->username, $request->password);
if (!$result['ok']) {
return response()->json(['status' => false, 'kc' => $result], 401);
}
$store->saveTokens($result['data']);
return response()->json([
'status' => true,
'message' => 'Logged in (password grant)',
'access_token' => $result['data']['access_token'],
]);
}
/**
* ✅ When to use tokenByRefreshToken():
* - access token expired / about to expire
* - do NOT ask password again
*/
public function refresh(Request $request, KeycloakOidcService $kc, KeycloakTokenStore $store)
{
$refresh = $store->refreshToken();
if (!$refresh) {
return response()->json(['status' => false, 'message' => 'no_refresh_token'], 401);
}
$result = $kc->tokenByRefreshToken($refresh);
if (!$result['ok']) {
$store->clear();
return response()->json(['status' => false, 'message' => 'refresh_failed', 'kc' => $result], 401);
}
// keep old refresh if KC did not rotate it
$data = $result['data'];
if (empty($data['refresh_token'])) {
$data['refresh_token'] = $refresh;
}
$store->saveTokens($data);
return response()->json([
'status' => true,
'message' => 'Token refreshed',
'access_token' => $data['access_token'],
]);
}
/**
* ✅ Example protected endpoint:
* - uses access token if valid
* - otherwise auto calls refresh internally
* - then calls /userinfo to get profile
*/
public function me(KeycloakOidcService $kc, KeycloakTokenStore $store)
{
$token = $store->getValidAccessToken($kc);
if (!$token) {
return response()->json(['status' => false, 'message' => 'session_expired_login_again'], 401);
}
$info = $kc->userinfo($token);
if (!$info['ok']) {
return response()->json(['status' => false, 'message' => 'userinfo_failed', 'kc' => $info], 401);
}
return response()->json([
'status' => true,
'userinfo' => $info['data'],
]);
}
/**
* ✅ When to use client_credentials():
* - service-to-service call (cron, backend job)
* - no user context
*/
public function serviceToken(KeycloakOidcService $kc)
{
$result = $kc->tokenByClientCredentials();
if (!$result['ok']) {
return response()->json(['status' => false, 'kc' => $result], 401);
}
return response()->json([
'status' => true,
'message' => 'Service token (client_credentials)',
'access_token' => $result['data']['access_token'],
]);
}
public function logout(KeycloakTokenStore $store)
{
// local logout only (clears stored tokens)
$store->clear();
return response()->json(['status' => true, 'message' => 'logged_out_locally']);
}
}
Routes
routes/api.php
use App\Http\Controllers\KeycloakAuthController;
Route::post('/kc/login', [KeycloakAuthController::class, 'loginWithPassword']);
Route::post('/kc/refresh', [KeycloakAuthController::class, 'refresh']);
Route::get('/kc/me', [KeycloakAuthController::class, 'me']);
Route::get('/kc/service-token', [KeycloakAuthController::class, 'serviceToken']);
Route::post('/kc/logout', [KeycloakAuthController::class, 'logout']);
Token-based SSO (Authorization header, NO cookies)
Database table (shared across domains)
user_keycloak_tokens table
CREATE TABLE user_keycloak_tokens (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
user_id BIGINT UNSIGNED NOT NULL,
access_token TEXT NOT NULL,
refresh_token TEXT NOT NULL,
expires_at INT NOT NULL,
revoked_at INT NULL,
device_id VARCHAR(100) NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
);
2️⃣ Model
app/Models/UserKeycloakToken.php
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class UserKeycloakToken extends Model
{
protected $table = 'user_keycloak_tokens';
protected $fillable = [
'user_id',
'access_token',
'refresh_token',
'expires_at',
'revoked_at',
'device_id',
];
protected $casts = [
'revoked_at' => 'integer',
'expires_at' => 'integer',
];
public function isRevoked(): bool
{
return !is_null($this->revoked_at);
}
public function isExpired(): bool
{
return time() >= $this->expires_at;
}
}
3️⃣ Login Controller (Password Grant → DB store)
app/Http/Controllers/AuthController.php
use App\Models\User;
use App\Models\UserKeycloakToken;
use App\Services\KeycloakOidcService;
use Illuminate\Http\Request;
public function login(Request $request, KeycloakOidcService $kc)
{
$request->validate([
'username' => 'required|string',
'password' => 'required|string',
]);
$res = $kc->tokenByPassword($request->username, $request->password);
if (!$res['ok']) {
return response()->json(['message' => 'login_failed'], 401);
}
$tokens = $res['data'];
// find or create local user
$user = User::firstOrCreate(
['email' => $request->username],
['name' => $request->username]
);
// store token in DB
$hash = hash('sha256', $tokens['access_token']);
UserKeycloakToken::updateOrCreate(
['user_id' => $user->id, 'device_id' => $deviceId],
[
'access_token_hash' => $hash,
'access_token' => $tokens['access_token'], // optional to keep; can remove later
'refresh_token' => $tokens['refresh_token'],
'expires_at' => time() + (int)$tokens['expires_in'],
'revoked_at' => null,
]
);
return response()->json([
'access_token' => $tokens['access_token'],
'token_type' => 'Bearer',
'expires_in' => $tokens['expires_in'],
]);
}
📌 Frontend stores ONLY access_token
4️⃣ JWT Verification Middleware (ALL domains)
This is the heart of Pattern A.
app/Http/Middleware/KeycloakJwtAuth.php
<?php
namespace App\Http\Middleware;
use Closure;
use Firebase\JWT\JWT;
use Firebase\JWT\JWK;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
use App\Models\UserKeycloakToken;
class KeycloakJwtAuth
{
public function handle(Request $request, Closure $next)
{
$auth = $request->header('Authorization');
if (!$auth || !str_starts_with($auth, 'Bearer ')) {
return response()->json(['message' => 'missing_token'], 401);
}
$token = substr($auth, 7);
// 1️⃣ Check DB (revocation)
$record = UserKeycloakToken::where('access_token', $token)->first();
if (!$record || $record->isRevoked()) {
return response()->json(['message' => 'token_revoked'], 401);
}
// 2️⃣ Verify JWT signature
try {
$jwks = Cache::remember('kc_jwks', 3600, function () {
return Http::get(
config('services.keycloak.base_url') .
'/realms/' . config('services.keycloak.realm') .
'/protocol/openid-connect/certs'
)->json();
});
$keys = JWK::parseKeySet($jwks);
$decoded = JWT::decode($token, $keys);
} catch (\Throwable $e) {
return response()->json(['message' => 'invalid_token'], 401);
}
// 3️⃣ Expired? Try refresh via DB refresh_token
if ($record->isExpired()) {
$kc = app(\App\Services\KeycloakOidcService::class);
$res = $kc->tokenByRefreshToken($record->refresh_token);
if (!$res['ok']) {
$record->update(['revoked_at' => time()]);
return response()->json(['message' => 'session_expired'], 401);
}
$data = $res['data'];
$record->update([
'access_token' => $data['access_token'],
'refresh_token' => $data['refresh_token'] ?? $record->refresh_token,
'expires_at' => time() + $data['expires_in'],
]);
// frontend must retry with new token
return response()->json([
'message' => 'token_refreshed_retry',
'access_token' => $data['access_token']
], 401);
}
// 4️⃣ Attach user info
$request->attributes->set('kc_user_id', $record->user_id);
return $next($request);
}
}
5️⃣ Register Middleware
app/Http/Kernel.php
protected $routeMiddleware = [
'kc.jwt' => \App\Http\Middleware\KeycloakJwtAuth::class,
];
6️⃣ Protected Routes (ANY DOMAIN)
Route::middleware(['kc.jwt'])->group(function () {
Route::get('/me', function (Request $request) {
return response()->json([
'user_id' => $request->get('kc_user_id'),
'status' => 'authenticated'
]);
});
});
7️⃣ Logout (Cross-Domain, DB-based)
public function logout(Request $request)
{
$auth = $request->header('Authorization');
if ($auth && str_starts_with($auth, 'Bearer ')) {
$token = substr($auth, 7);
UserKeycloakToken::where('access_token', $token)
->update(['revoked_at' => time()]);
}
return response()->json(['message' => 'logged_out_everywhere']);
}
======================OR===============================
public function logout(Request $request)
{
$auth = $request->header('Authorization');
if ($auth && str_starts_with($auth, 'Bearer ')) {
$token = substr($auth, 7);
UserKeycloakToken::where('access_token', $token)
->update(['revoked_at' => now()]);
}
return response()->json(['message' => 'logged_out_everywhere']);
}
in handle methode middleware
// 0️⃣ If token exists, check revoked in DB
if ($accessToken) {
$tokenHash = hash('sha256', $token);
// ✅ Revocation check (fast)
$row = UserKeycloakToken::where('access_token_hash', $tokenHash)->first();
// If token not found in DB OR revoked => treat as logged out
if (!$row || $row->revoked_at !== null) {
Log::warning("⛔ [KC_AUTO] Token revoked or not found in DB", [
'found' => $row ? 'YES' : 'NO',
'revoked_at' => $row->revoked_at ?? null,
]);
// force local logout too
Auth::logout();
session()->invalidate();
session()->regenerateToken();
// For web redirect, for API 401
return $request->expectsJson()
? response()->json(['status' => false, 'message' => 'token_revoked'], 401)
: redirect('/login');
}
$now = time();
$skew = 30; // seconds (refresh slightly before expiry)
if (!empty($row->expires_at) && $now >= ((int)$row->expires_at - $skew)) {
Log::info('🔁 [KC_AUTO] Access token expired/expiring, refreshing...', [
'user_id' => $row->user_id,
'expires_at' => $row->expires_at,
'now' => $now,
]);
/** @var KeycloakOidcService $kc */
$kc = app(KeycloakOidcService::class);
$res = $kc->tokenByRefreshToken($row->refresh_token);
if (!$res['ok']) {
// Refresh failed => revoke + force login
$row->update(['revoked_at' => $now]);
Log::warning('⛔ [KC_AUTO] Refresh failed; token revoked', [
'user_id' => $row->user_id,
'kc' => $res,
]);
if (Auth::check()) Auth::logout();
$request->session()->invalidate();
$request->session()->regenerateToken();
return $request->expectsJson()
? response()->json(['status' => false, 'message' => 'session_expired'], 401)
: redirect('/login');
}
$data = $res['data'];
// Update DB with new tokens
$newAccess = $data['access_token'];
$newHash = hash('sha256', $newAccess);
$row->update([
'access_token_hash' => $newHash,
'access_token' => $newAccess, // optional: you can stop storing full token later
'refresh_token' => $data['refresh_token'] ?? $row->refresh_token,
'expires_at' => $now + (int)($data['expires_in'] ?? 0),
'revoked_at' => null,
]);
// IMPORTANT: frontend must replace token and retry request
return response()->json([
'status' => false,
'message' => 'token_refreshed_retry',
'access_token' => $newAccess,
'expires_in' => (int)($data['expires_in'] ?? 0),
], 401);
}
}
Token Introspection (check “active” server-side) using api endpoints curl
POST $KC_BASE/realms/$REALM/protocol/openid-connect/token/introspect
curl -s -X POST "$KC_BASE/realms/$REALM/protocol/openid-connect/token/introspect" \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "client_id=$CLIENT_ID" \
-d "client_secret=$CLIENT_SECRET" \
-d "token=$ACCESS_TOKEN"
JWT mapping
Introspection returns JSON like active: true/false, plus claims.
Use this when you suspect revocation, session logout, or token not valid for realm/client.
Userinfo (standard OIDC profile endpoint)
GET $KC_BASE/realms/$REALM/protocol/openid-connect/userinfo
curl -s "$KC_BASE/realms/$REALM/protocol/openid-connect/userinfo" \
-H "Authorization: Bearer $ACCESS_TOKEN"
JWT mapping
Requires an access_token with proper scopes (typically openid and often profile / email).
Useful to verify what Keycloak will expose to a client.
Logout (end session)
GET/POST $KC_BASE/realms/$REALM/protocol/openid-connect/logout
RP-initiated logout (recommended for browser SSO)
curl -i -X POST "$KC_BASE/realms/$REALM/protocol/openid-connect/logout" \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "client_id=$CLIENT_ID" \
-d "client_secret=$CLIENT_SECRET" \
-d "refresh_token=$REFRESH_TOKEN"
Browser redirect style (often used in apps)
# id_token_hint is typically ID_TOKEN (JWT)
# post_logout_redirect_uri must be whitelisted in client settings
"$KC_BASE/realms/$REALM/protocol/openid-connect/logout?id_token_hint=$ID_TOKEN&post_logout_redirect_uri=http://yourapp/sso/logged-out"
JWT mapping
id_token_hint must be the ID Token (JWT).
For server-side logout with refresh token: refresh_token is used.
JWKS / Certs (public keys for JWT verification)
GET $KC_BASE/realms/$REALM/protocol/openid-connect/certs
curl -s "$KC_BASE/realms/$REALM/protocol/openid-connect/certs"
JWT mapping
Use JWKS to verify JWT signature (kid header selects the key).
OpenID Provider configuration (discovery)
GET $KC_BASE/realms/$REALM/.well-known/openid-configuration
curl -s "$KC_BASE/realms/$REALM/.well-known/openid-configuration"
JWT mapping
Confirms the correct issuer (issuer == iss claim).
Authorization endpoint (browser login)
GET $KC_BASE/realms/$REALM/protocol/openid-connect/auth
Example (Authorization Code flow):
"$KC_BASE/realms/$REALM/protocol/openid-connect/auth?client_id=$CLIENT_ID&response_type=code&scope=openid%20profile&redirect_uri=http://localhost/callback"
JWT mapping
This returns code, then you exchange it at /token to get JWTs.
Token Exchange (if enabled)
URL
POST $KC_BASE/realms/$REALM/protocol/openid-connect/token
curl -s -X POST "$KC_BASE/realms/$REALM/protocol/openid-connect/token" \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "grant_type=urn:ietf:params:oauth:grant-type:token-exchange" \
-d "client_id=$CLIENT_ID" \
-d "client_secret=$CLIENT_SECRET" \
-d "subject_token=$ACCESS_TOKEN" \
-d "requested_token_type=urn:ietf:params:oauth:token-type:access_token"
JWT mapping
Returns another access_token JWT, often with different aud / roles, depending on policy.
Get admin token (for Admin API calls)
Usually you use an admin client (or a service account with realm-management roles).
ADMIN_CLIENT="admin-cli" # or your confidential admin client
ADMIN_USER="admin"
ADMIN_PASS="adminpass"
ADMIN_TOKEN=$(curl -s -X POST "$KC_BASE/realms/$REALM/protocol/openid-connect/token" \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "grant_type=password" \
-d "client_id=$ADMIN_CLIENT" \
-d "username=$ADMIN_USER" \
-d "password=$ADMIN_PASS" | jq -r .access_token)
JWT mapping
Admin token JWT must have realm-management roles like:
view-users, query-users, manage-users, view-clients, manage-clients, etc.
Users(list,search and create user and set password)
curl -s "$KC_BASE/admin/realms/$REALM/users?max=20" \
-H "Authorization: Bearer $ADMIN_TOKEN"
Search users
curl -s "$KC_BASE/admin/realms/$REALM/users?search=ashwani" \
-H "Authorization: Bearer $ADMIN_TOKEN"
Get user by id
USER_ID="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
curl -s "$KC_BASE/admin/realms/$REALM/users/$USER_ID" \
-H "Authorization: Bearer $ADMIN_TOKEN"
Create user
curl -s -X POST "$KC_BASE/admin/realms/$REALM/users" \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"enabled": true,
"username": "newuser",
"email": "newuser@example.com",
"firstName": "New",
"lastName": "User"
}' -i
Set password
curl -s -X PUT "$KC_BASE/admin/realms/$REALM/users/$USER_ID/reset-password" \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d '{"type":"password","temporary":false,"value":"StrongPass@123"}'
List and get clients
curl -s "$KC_BASE/admin/realms/$REALM/clients?max=50" \
-H "Authorization: Bearer $ADMIN_TOKEN"
Get client by clientId (search then pick id)
curl -s "$KC_BASE/admin/realms/$REALM/clients?clientId=$CLIENT_ID" \
-H "Authorization: Bearer $ADMIN_TOKEN"
realm and client roles
curl -s "$KC_BASE/admin/realms/$REALM/roles" \
-H "Authorization: Bearer $ADMIN_TOKEN"
Client roles
CLIENT_UUID="..." # internal UUID from clients list
curl -s "$KC_BASE/admin/realms/$REALM/clients/$CLIENT_UUID/roles" \
-H "Authorization: Bearer $ADMIN_TOKEN"
Realm groups
curl -s "$KC_BASE/admin/realms/$REALM/groups?max=50" \
-H "Authorization: Bearer $ADMIN_TOKEN"
Sessions / Logout user sessions (admin)
User sessions
curl -s "$KC_BASE/admin/realms/$REALM/users/$USER_ID/sessions" \
-H "Authorization: Bearer $ADMIN_TOKEN"
Logout user (invalidate sessions)
curl -s -X POST "$KC_BASE/admin/realms/$REALM/users/$USER_ID/logout" \
-H "Authorization: Bearer $ADMIN_TOKEN"
“Which JWT is used where?” quick mapping
Laravel code to get all api endpoints
env + config
.env
KEYCLOAK_BASE_URL=http://localhost:8080
KEYCLOAK_REALM=master
KEYCLOAK_CLIENT_ID=myapp
KEYCLOAK_CLIENT_SECRET=xxxx
# Optional: if you want Admin API calls via service account or admin-cli
KEYCLOAK_ADMIN_REALM=master
KEYCLOAK_ADMIN_CLIENT_ID=admin-cli
KEYCLOAK_ADMIN_CLIENT_SECRET=
KEYCLOAK_ADMIN_USERNAME=admin
KEYCLOAK_ADMIN_PASSWORD=adminpass
config/services.php
'keycloak' => [
'base_url' => env('KEYCLOAK_BASE_URL'),
'realm' => env('KEYCLOAK_REALM'),
'client_id' => env('KEYCLOAK_CLIENT_ID'),
'client_secret' => env('KEYCLOAK_CLIENT_SECRET'),
'admin_realm' => env('KEYCLOAK_ADMIN_REALM', env('KEYCLOAK_REALM')),
'admin_client_id' => env('KEYCLOAK_ADMIN_CLIENT_ID', 'admin-cli'),
'admin_client_secret' => env('KEYCLOAK_ADMIN_CLIENT_SECRET'),
'admin_username' => env('KEYCLOAK_ADMIN_USERNAME'),
'admin_password' => env('KEYCLOAK_ADMIN_PASSWORD'),
],
KeycloakService (ALL API endpoints)
Create: app/Services/KeycloakService.php
<?php
namespace App\Services;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Cache;
class KeycloakService
{
private string $base;
private string $realm;
private string $clientId;
private ?string $clientSecret;
public function __construct()
{
$this->base = rtrim(config('services.keycloak.base_url'), '/');
$this->realm = config('services.keycloak.realm');
$this->clientId = config('services.keycloak.client_id');
$this->clientSecret = config('services.keycloak.client_secret');
}
// -------------------------
// URL builders
// -------------------------
public function issuer(): string
{
return "{$this->base}/realms/{$this->realm}";
}
public function tokenUrl(): string
{
return "{$this->issuer()}/protocol/openid-connect/token";
}
public function introspectUrl(): string
{
return "{$this->issuer()}/protocol/openid-connect/token/introspect";
}
public function userinfoUrl(): string
{
return "{$this->issuer()}/protocol/openid-connect/userinfo";
}
public function logoutUrl(): string
{
return "{$this->issuer()}/protocol/openid-connect/logout";
}
public function jwksUrl(): string
{
return "{$this->issuer()}/protocol/openid-connect/certs";
}
public function openidConfigUrl(): string
{
return "{$this->issuer()}/.well-known/openid-configuration";
}
public function authUrl(): string
{
return "{$this->issuer()}/protocol/openid-connect/auth";
}
public function adminBaseUrl(string $realm = null): string
{
$r = $realm ?: $this->realm;
return "{$this->base}/admin/realms/{$r}";
}
// -------------------------
// 1) TOKEN APIs
// -------------------------
public function tokenPassword(string $username, string $password, array $extra = []): array
{
$payload = array_merge([
'grant_type' => 'password',
'client_id' => $this->clientId,
'username' => $username,
'password' => $password,
// OIDC scopes if you need id_token/userinfo etc.
'scope' => 'openid profile email',
], $extra);
// confidential client? send secret
if (!empty($this->clientSecret)) {
$payload['client_secret'] = $this->clientSecret;
}
$res = Http::asForm()->post($this->tokenUrl(), $payload);
return [
'ok' => $res->successful(),
'status' => $res->status(),
'data' => $res->json(),
'raw' => $res->body(),
];
}
public function tokenClientCredentials(array $extra = []): array
{
$payload = array_merge([
'grant_type' => 'client_credentials',
'client_id' => $this->clientId,
], $extra);
if (!empty($this->clientSecret)) {
$payload['client_secret'] = $this->clientSecret;
}
$res = Http::asForm()->post($this->tokenUrl(), $payload);
return [
'ok' => $res->successful(),
'status' => $res->status(),
'data' => $res->json(),
'raw' => $res->body(),
];
}
public function refreshToken(string $refreshToken, array $extra = []): array
{
$payload = array_merge([
'grant_type' => 'refresh_token',
'client_id' => $this->clientId,
'refresh_token' => $refreshToken,
], $extra);
if (!empty($this->clientSecret)) {
$payload['client_secret'] = $this->clientSecret;
}
$res = Http::asForm()->post($this->tokenUrl(), $payload);
return [
'ok' => $res->successful(),
'status' => $res->status(),
'data' => $res->json(),
'raw' => $res->body(),
];
}
// Token Exchange (if enabled)
public function tokenExchange(string $subjectToken, array $opts = []): array
{
$payload = array_merge([
'grant_type' => 'urn:ietf:params:oauth:grant-type:token-exchange',
'client_id' => $this->clientId,
'subject_token' => $subjectToken,
'requested_token_type' => 'urn:ietf:params:oauth:token-type:access_token',
], $opts);
if (!empty($this->clientSecret)) {
$payload['client_secret'] = $this->clientSecret;
}
$res = Http::asForm()->post($this->tokenUrl(), $payload);
return [
'ok' => $res->successful(),
'status' => $res->status(),
'data' => $res->json(),
'raw' => $res->body(),
];
}
// -------------------------
// 2) INTROSPECT
// -------------------------
public function introspect(string $token, array $clientAuthOverride = []): array
{
$payload = [
'token' => $token,
'client_id' => $clientAuthOverride['client_id'] ?? $this->clientId,
];
$secret = $clientAuthOverride['client_secret'] ?? $this->clientSecret;
if (!empty($secret)) {
$payload['client_secret'] = $secret;
}
$res = Http::asForm()->post($this->introspectUrl(), $payload);
return [
'ok' => $res->successful(),
'status' => $res->status(),
'data' => $res->json(),
'raw' => $res->body(),
];
}
// -------------------------
// 3) USERINFO
// -------------------------
public function userinfo(string $accessToken): array
{
$res = Http::withToken($accessToken)->get($this->userinfoUrl());
return [
'ok' => $res->successful(),
'status' => $res->status(),
'data' => $res->json(),
'raw' => $res->body(),
];
}
// -------------------------
// 4) LOGOUT
// -------------------------
// Server-side logout (recommended for apps) using refresh_token
public function logoutByRefreshToken(string $refreshToken): array
{
$payload = [
'client_id' => $this->clientId,
'refresh_token' => $refreshToken,
];
if (!empty($this->clientSecret)) {
$payload['client_secret'] = $this->clientSecret;
}
$res = Http::asForm()->post($this->logoutUrl(), $payload);
return [
'ok' => $res->successful(),
'status' => $res->status(),
'data' => $res->json(), // often empty
'raw' => $res->body(),
];
}
// Browser redirect logout URL (id_token_hint is ID_TOKEN JWT)
public function logoutRedirectUrl(string $idToken, string $postLogoutRedirectUri): string
{
$qs = http_build_query([
'id_token_hint' => $idToken,
'post_logout_redirect_uri' => $postLogoutRedirectUri,
'client_id' => $this->clientId,
]);
return $this->logoutUrl() . '?' . $qs;
}
// -------------------------
// 5) JWKS + DISCOVERY
// -------------------------
public function jwks(bool $cached = true): array
{
$cacheKey = "kc:jwks:{$this->realm}";
if ($cached) {
return Cache::remember($cacheKey, 60, function () {
$res = Http::get($this->jwksUrl());
return $res->json() ?: [];
});
}
$res = Http::get($this->jwksUrl());
return $res->json() ?: [];
}
public function openidConfiguration(bool $cached = true): array
{
$cacheKey = "kc:oidc-config:{$this->realm}";
if ($cached) {
return Cache::remember($cacheKey, 60, function () {
$res = Http::get($this->openidConfigUrl());
return $res->json() ?: [];
});
}
$res = Http::get($this->openidConfigUrl());
return $res->json() ?: [];
}
// -------------------------
// 6) AUTH URL (for browser login)
// -------------------------
public function authorizationUrl(string $redirectUri, string $state, array $opts = []): string
{
$params = array_merge([
'client_id' => $this->clientId,
'response_type' => 'code',
'scope' => 'openid profile email',
'redirect_uri' => $redirectUri,
'state' => $state,
], $opts);
return $this->authUrl() . '?' . http_build_query($params);
}
// -------------------------
// 7) ADMIN TOKEN + ADMIN APIs
// -------------------------
public function adminToken(): string
{
// Cached token (avoid fetching every request)
return Cache::remember('kc:admin_token', 50, function () {
$base = rtrim(config('services.keycloak.base_url'), '/');
$realm = config('services.keycloak.admin_realm');
$url = "{$base}/realms/{$realm}/protocol/openid-connect/token";
$payload = [
'grant_type' => 'password',
'client_id' => config('services.keycloak.admin_client_id'),
'username' => config('services.keycloak.admin_username'),
'password' => config('services.keycloak.admin_password'),
];
$secret = config('services.keycloak.admin_client_secret');
if (!empty($secret)) {
$payload['client_secret'] = $secret;
}
$res = Http::asForm()->post($url, $payload);
$json = $res->json();
if (!$res->successful() || empty($json['access_token'])) {
throw new \RuntimeException("Keycloak adminToken failed: {$res->status()} {$res->body()}");
}
return $json['access_token'];
});
}
private function adminRequest()
{
return Http::withToken($this->adminToken())
->acceptJson()
->asJson();
}
// Users
public function adminUsersList(int $max = 20): array
{
$res = $this->adminRequest()->get($this->adminBaseUrl() . "/users", ['max' => $max]);
return ['ok'=>$res->successful(),'status'=>$res->status(),'data'=>$res->json(),'raw'=>$res->body()];
}
public function adminUsersSearch(string $q, int $max = 20): array
{
$res = $this->adminRequest()->get($this->adminBaseUrl() . "/users", ['search' => $q, 'max' => $max]);
return ['ok'=>$res->successful(),'status'=>$res->status(),'data'=>$res->json(),'raw'=>$res->body()];
}
public function adminUserGet(string $userId): array
{
$res = $this->adminRequest()->get($this->adminBaseUrl() . "/users/{$userId}");
return ['ok'=>$res->successful(),'status'=>$res->status(),'data'=>$res->json(),'raw'=>$res->body()];
}
public function adminUserCreate(array $user): array
{
$res = $this->adminRequest()->post($this->adminBaseUrl() . "/users", $user);
return ['ok'=>$res->successful(),'status'=>$res->status(),'data'=>$res->json(),'raw'=>$res->body()];
}
public function adminUserResetPassword(string $userId, string $password, bool $temporary = false): array
{
$res = $this->adminRequest()->put($this->adminBaseUrl() . "/users/{$userId}/reset-password", [
'type' => 'password',
'temporary' => $temporary,
'value' => $password,
]);
return ['ok'=>$res->successful(),'status'=>$res->status(),'data'=>$res->json(),'raw'=>$res->body()];
}
public function adminUserSessions(string $userId): array
{
$res = $this->adminRequest()->get($this->adminBaseUrl() . "/users/{$userId}/sessions");
return ['ok'=>$res->successful(),'status'=>$res->status(),'data'=>$res->json(),'raw'=>$res->body()];
}
public function adminUserLogout(string $userId): array
{
$res = $this->adminRequest()->post($this->adminBaseUrl() . "/users/{$userId}/logout");
return ['ok'=>$res->successful(),'status'=>$res->status(),'data'=>$res->json(),'raw'=>$res->body()];
}
// Clients
public function adminClientsList(int $max = 50): array
{
$res = $this->adminRequest()->get($this->adminBaseUrl() . "/clients", ['max' => $max]);
return ['ok'=>$res->successful(),'status'=>$res->status(),'data'=>$res->json(),'raw'=>$res->body()];
}
public function adminClientsByClientId(string $clientId): array
{
$res = $this->adminRequest()->get($this->adminBaseUrl() . "/clients", ['clientId' => $clientId]);
return ['ok'=>$res->successful(),'status'=>$res->status(),'data'=>$res->json(),'raw'=>$res->body()];
}
// Roles
public function adminRealmRoles(): array
{
$res = $this->adminRequest()->get($this->adminBaseUrl() . "/roles");
return ['ok'=>$res->successful(),'status'=>$res->status(),'data'=>$res->json(),'raw'=>$res->body()];
}
public function adminClientRoles(string $clientUuid): array
{
$res = $this->adminRequest()->get($this->adminBaseUrl() . "/clients/{$clientUuid}/roles");
return ['ok'=>$res->successful(),'status'=>$res->status(),'data'=>$res->json(),'raw'=>$res->body()];
}
// Groups
public function adminGroups(int $max = 50): array
{
$res = $this->adminRequest()->get($this->adminBaseUrl() . "/groups", ['max' => $max]);
return ['ok'=>$res->successful(),'status'=>$res->status(),'data'=>$res->json(),'raw'=>$res->body()];
}
}
Why this Laravel JWT verification code exists
When you NEED this Laravel JWT verification code
ou must use this code when any of the following is true:
✅ Case 1: You have protected APIs
Example:
/api/user/profile
/api/hospital/update
/api/vehicle/add
/api/admin/*
👉 Every protected API must verify JWT
Why?
Because APIs do not have sessions.
They rely only on JWT to know who the user is.
✅ Case 2: You are NOT calling Keycloak on every request
Most production systems do not call Keycloak for every API request (too slow).
👉 Instead:
Keycloak issues JWT once
Laravel verifies JWT locally
This code replaces:
"Call Keycloak → check user → allow request"
with:
"Verify JWT locally → allow request"
✅ Case 3: You auto-login users using cookies (like your code)
Your middleware:
$keycloak_access_token (cookie)
Laravel must verify:
Token is real
Token is not expired
Token was issued by your Keycloak
Token belongs to your client
Otherwise:
A fake cookie = account takeover risk
✅ Case 4: You trust JWT claims (email, role, phone)
You are doing:
$email = $payload['email'];
$role = $payload['role'];
👉 This is DANGEROUS unless JWT signature is verified.
Why?
Because payload can be modified easily if not verified.
What JWT verification actually does (step-by-step)
Step 1: Decode JWT (NOT security)
header.payload.signature
Decoding means:
Read JSON
No security yet
Anyone can decode a JWT.
Step 2: Verify signature (REAL security) 🔐
This is where JWKS comes in.
Keycloak signs JWT using a private key.
Laravel must verify using the public key.
Keycloak (private key) → signs token
Laravel (public key) → verifies token
👉 If any byte of JWT is changed → verification fails.
This proves:
Token is issued by Keycloak
Token is not tampered
Step 3: Validate claims (business rules)
What is JWKS and why Laravel uses it
JWKS = JSON Web Key Set
Endpoint:
/realms/{realm}/protocol/openid-connect/certs
Contains:
Public RSA keys
Identified by kid
JWT header contains:
{ "kid": "abc123" }
Laravel:
Reads kid
Finds matching public key from JWKS
Verifies signature
👉 No secret stored in Laravel
👉 Key rotation supported automatically
5️⃣
When JWT verification is ENOUGH (no introspection needed)
JWT verification alone is enough when:
Normal API requests
Read operations
Performance matters
Token expiry is short (5–15 min)
Example:
Route::get('/user/profile')->middleware('kc.jwt');
This gives:
🔥 Fast
🚀 No Keycloak call
🔒 Secure
When JWT verification is NOT enough
JWT verification does NOT tell:
User logged out
Session revoked
User disabled
Admin forced logout
JWT stays valid until exp.
7️⃣ When to ADD introspection on top of JWT
Where this code sits in your Laravel app
JWT verification (decode + verify signature via JWKS)
Install JWT lib (recommended)
In Laravel, the easiest is firebase/php-jwt:
composer require firebase/php-jwt
3.2 JwtVerifier helper
Create: app/Support/JwtVerifier.php
<?php
namespace App\Support;
use App\Services\KeycloakService;
use Firebase\JWT\JWT;
use Firebase\JWT\Key;
use Illuminate\Support\Facades\Log;
class JwtVerifier
{
public function __construct(private KeycloakService $kc) {}
public function decodeAndVerify(string $jwt, array $checks = []): ?array
{
try {
$parts = explode('.', $jwt);
if (count($parts) !== 3) {
Log::warning("[JWT] Malformed token (not 3 parts)");
return null;
}
$header = json_decode($this->b64urlDecode($parts[0]), true) ?: [];
$kid = $header['kid'] ?? null;
$alg = $header['alg'] ?? null;
if (!$kid || !$alg) {
Log::warning("[JWT] Missing kid/alg in header", $header);
return null;
}
// Load JWKS and find key by kid
$jwks = $this->kc->jwks(true);
$jwk = $this->findJwkByKid($jwks, $kid);
if (!$jwk) {
Log::warning("[JWT] No matching JWK for kid", ['kid' => $kid]);
return null;
}
// Convert JWK to PEM public key
$publicKeyPem = $this->jwkToPem($jwk);
if (!$publicKeyPem) {
Log::warning("[JWT] Failed to convert JWK to PEM", ['kid' => $kid]);
return null;
}
// Verify signature (this returns payload as object)
$payloadObj = JWT::decode($jwt, new Key($publicKeyPem, $alg));
$payload = json_decode(json_encode($payloadObj), true);
// Extra claim checks (iss, aud, exp etc.)
if (!$this->validateClaims($payload, $checks)) {
return null;
}
return $payload;
} catch (\Throwable $e) {
Log::warning("[JWT] Verification failed", ['err' => $e->getMessage()]);
return null;
}
}
private function validateClaims(array $payload, array $checks): bool
{
$now = time();
if (!empty($payload['exp']) && $payload['exp'] < $now) {
Log::warning("[JWT] Token expired", ['exp' => $payload['exp'], 'now' => $now]);
return false;
}
if (!empty($checks['iss'])) {
$iss = $payload['iss'] ?? null;
if ($iss !== $checks['iss']) {
Log::warning("[JWT] iss mismatch", ['expected'=>$checks['iss'], 'actual'=>$iss]);
return false;
}
}
if (!empty($checks['aud'])) {
$audClaim = $payload['aud'] ?? [];
$aud = is_string($audClaim) ? [$audClaim] : (is_array($audClaim) ? $audClaim : []);
if (!in_array($checks['aud'], $aud, true)) {
Log::warning("[JWT] aud mismatch", ['expected'=>$checks['aud'], 'actual'=>$aud]);
return false;
}
}
return true;
}
private function findJwkByKid(array $jwks, string $kid): ?array
{
$keys = $jwks['keys'] ?? [];
foreach ($keys as $k) {
if (($k['kid'] ?? null) === $kid) return $k;
}
return null;
}
private function jwkToPem(array $jwk): ?string
{
// Works for RSA keys (typical Keycloak RS256)
if (($jwk['kty'] ?? '') !== 'RSA') return null;
$n = $jwk['n'] ?? null;
$e = $jwk['e'] ?? null;
if (!$n || !$e) return null;
// Build RSA public key from modulus/exponent
$modulus = $this->b64urlDecodeToBin($n);
$exponent = $this->b64urlDecodeToBin($e);
$rsaPubKey = $this->buildRsaPublicKeyDer($modulus, $exponent);
if (!$rsaPubKey) return null;
$pem = "-----BEGIN PUBLIC KEY-----\n" .
chunk_split(base64_encode($rsaPubKey), 64, "\n") .
"-----END PUBLIC KEY-----\n";
return $pem;
}
private function buildRsaPublicKeyDer(string $n, string $e): ?string
{
// Minimal DER builder for SubjectPublicKeyInfo (RSA)
// This is production-used pattern; stable for RS256 verification.
$mod = $this->asn1Integer($n);
$exp = $this->asn1Integer($e);
$seq = $this->asn1Sequence($mod . $exp);
// rsaEncryption OID: 1.2.840.113549.1.1.1 + NULL
$algo = $this->asn1Sequence(
$this->asn1Oid("\x2a\x86\x48\x86\xf7\x0d\x01\x01\x01") . // 1.2.840.113549.1.1.1
$this->asn1Null()
);
$bitString = $this->asn1BitString($seq);
return $this->asn1Sequence($algo . $bitString);
}
private function asn1Length(int $len): string
{
if ($len < 0x80) return chr($len);
$out = '';
while ($len > 0) {
$out = chr($len & 0xff) . $out;
$len >>= 8;
}
return chr(0x80 | strlen($out)) . $out;
}
private function asn1Integer(string $bin): string
{
// ensure positive integer
if (ord($bin[0]) > 0x7f) $bin = "\x00" . $bin;
return "\x02" . $this->asn1Length(strlen($bin)) . $bin;
}
private function asn1Sequence(string $bin): string
{
return "\x30" . $this->asn1Length(strlen($bin)) . $bin;
}
private function asn1BitString(string $bin): string
{
// prepend 0 unused bits
$bin = "\x00" . $bin;
return "\x03" . $this->asn1Length(strlen($bin)) . $bin;
}
private function asn1Null(): string
{
return "\x05\x00";
}
private function asn1Oid(string $oidBin): string
{
return "\x06" . $this->asn1Length(strlen($oidBin)) . $oidBin;
}
private function b64urlDecode(string $in): string
{
$remainder = strlen($in) % 4;
if ($remainder) $in .= str_repeat('=', 4 - $remainder);
return base64_decode(strtr($in, '-_', '+/')) ?: '';
}
private function b64urlDecodeToBin(string $in): string
{
return $this->b64urlDecode($in);
}
}
3.3 Middleware: validate JWT (Bearer or Cookie) and attach payload
Create: app/Http/Middleware/KeycloakJwtAuth.php
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
use App\Support\JwtVerifier;
class KeycloakJwtAuth
{
public function __construct(private JwtVerifier $verifier) {}
public function handle(Request $request, Closure $next)
{
// 1) Token from Authorization header or cookie
$token = $this->getBearerToken($request) ?: $request->cookie('keycloak_access_token');
if (!$token) {
return response()->json(['ok' => false, 'message' => 'Missing access token'], 401);
}
// 2) Verify signature + claims
$expectedIss = rtrim(config('services.keycloak.base_url'), '/') . '/realms/' . config('services.keycloak.realm');
$expectedAud = config('services.keycloak.client_id');
$payload = $this->verifier->decodeAndVerify($token, [
'iss' => $expectedIss,
'aud' => $expectedAud, // if your token aud is different, adjust here
]);
if (!$payload) {
return response()->json(['ok' => false, 'message' => 'Invalid token'], 401);
}
// 3) Add payload for controllers
$request->attributes->set('kc_jwt', $payload);
Log::info("[KC_JWT] ok", [
'sub' => $payload['sub'] ?? null,
'email' => $payload['email'] ?? null,
'aud' => $payload['aud'] ?? null,
]);
return $next($request);
}
private function getBearerToken(Request $request): ?string
{
$h = $request->header('Authorization');
if (!$h) return null;
if (preg_match('/Bearer\s+(.+)/i', $h, $m)) return trim($m[1]);
return null;
}
}
Register middleware in app/Http/Kernel.php:
protected $routeMiddleware = [
// ...
'kc.jwt' => \App\Http\Middleware\KeycloakJwtAuth::class,
];
4) Example Laravel Routes + Controller usage for each endpoint
Routes: routes/api.php
use Illuminate\Support\Facades\Route;
use App\Http\Controllers\KeycloakApiController;
Route::post('/kc/token/password', [KeycloakApiController::class, 'tokenPassword']);
Route::post('/kc/token/client', [KeycloakApiController::class, 'tokenClient']);
Route::post('/kc/token/refresh', [KeycloakApiController::class, 'tokenRefresh']);
Route::post('/kc/token/exchange', [KeycloakApiController::class, 'tokenExchange']);
Route::post('/kc/introspect', [KeycloakApiController::class, 'introspect']);
Route::get('/kc/userinfo', [KeycloakApiController::class, 'userinfo'])->middleware('kc.jwt');
Route::post('/kc/logout/refresh', [KeycloakApiController::class, 'logoutByRefresh']);
Route::get('/kc/logout/url', [KeycloakApiController::class, 'logoutRedirectUrl']);
Route::get('/kc/jwks', [KeycloakApiController::class, 'jwks']);
Route::get('/kc/openid-config', [KeycloakApiController::class, 'openidConfig']);
// Admin APIs (protect with your own auth or IP restriction)
Route::get('/kc/admin/users', [KeycloakApiController::class, 'adminUsers']);
Route::get('/kc/admin/users/search', [KeycloakApiController::class, 'adminUsersSearch']);
Route::get('/kc/admin/users/{id}', [KeycloakApiController::class, 'adminUserGet']);
Route::post('/kc/admin/users', [KeycloakApiController::class, 'adminUserCreate']);
Route::post('/kc/admin/users/{id}/logout', [KeycloakApiController::class, 'adminUserLogout']);
Route::get('/kc/admin/clients', [KeycloakApiController::class, 'adminClients']);
Route::get('/kc/admin/roles', [KeycloakApiController::class, 'adminRealmRoles']);
Route::get('/kc/admin/groups', [KeycloakApiController::class, 'adminGroups']);
Controller: app/Http/Controllers/KeycloakApiController.php
<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
use App\Services\KeycloakService;
class KeycloakApiController extends Controller
{
public function __construct(private KeycloakService $kc) {}
// ---- TOKEN ----
public function tokenPassword(Request $r)
{
return response()->json(
$this->kc->tokenPassword($r->username, $r->password)
);
}
public function tokenClient()
{
return response()->json(
$this->kc->tokenClientCredentials()
);
}
public function tokenRefresh(Request $r)
{
return response()->json(
$this->kc->refreshToken($r->refresh_token)
);
}
public function tokenExchange(Request $r)
{
return response()->json(
$this->kc->tokenExchange($r->subject_token, $r->only(['audience','requested_subject','scope']))
);
}
// ---- INTROSPECT ----
public function introspect(Request $r)
{
return response()->json(
$this->kc->introspect($r->token)
);
}
// ---- USERINFO (JWT middleware already verified signature) ----
public function userinfo(Request $r)
{
// Option A: Use userinfo endpoint (needs access_token)
$token = $r->bearerToken() ?: $r->cookie('keycloak_access_token');
return response()->json($this->kc->userinfo($token));
// Option B: Directly return decoded verified claims
// return response()->json(['ok'=>true, 'claims'=>$r->attributes->get('kc_jwt')]);
}
// ---- LOGOUT ----
public function logoutByRefresh(Request $r)
{
return response()->json(
$this->kc->logoutByRefreshToken($r->refresh_token)
);
}
public function logoutRedirectUrl(Request $r)
{
$idToken = $r->query('id_token');
$redirect = $r->query('post_logout_redirect_uri', url('/'));
return response()->json([
'ok' => true,
'url' => $this->kc->logoutRedirectUrl($idToken, $redirect),
]);
}
// ---- JWKS + DISCOVERY ----
public function jwks()
{
return response()->json(['ok'=>true, 'data'=>$this->kc->jwks(true)]);
}
public function openidConfig()
{
return response()->json(['ok'=>true, 'data'=>$this->kc->openidConfiguration(true)]);
}
// ---- ADMIN ----
public function adminUsers(Request $r)
{
return response()->json($this->kc->adminUsersList((int)($r->max ?? 20)));
}
public function adminUsersSearch(Request $r)
{
return response()->json($this->kc->adminUsersSearch($r->q ?? '', (int)($r->max ?? 20)));
}
public function adminUserGet(string $id)
{
return response()->json($this->kc->adminUserGet($id));
}
public function adminUserCreate(Request $r)
{
return response()->json($this->kc->adminUserCreate($r->all()));
}
public function adminUserLogout(string $id)
{
return response()->json($this->kc->adminUserLogout($id));
}
public function adminClients(Request $r)
{
return response()->json($this->kc->adminClientsList((int)($r->max ?? 50)));
}
public function adminRealmRoles()
{
return response()->json($this->kc->adminRealmRoles());
}
public function adminGroups(Request $r)
{
return response()->json($this->kc->adminGroups((int)($r->max ?? 50)));
}
}
Laravel code to get all api endpoints
Top comments (0)