Adding Group Membership Claims in Keycloak for wizbrand

Posted by

Limited Time Offer!

For Less Than the Cost of a Starbucks Coffee, Access All DevOpsSchool Videos on YouTube Unlimitedly.
Master DevOps, SRE, DevSecOps Skills!

Enroll Now

This guide explains how to configure Keycloak so that your Laravel app (wizbrand-web) receives organization and role membership in the userโ€™s token.

Weโ€™ll use Groups to represent organizations and their roles, then expose them via a Group Membership mapper.


Why Groups?

In wizbrand, a user can belong to multiple organizations with different roles (Admin, Manager, User).
For example:

  • Ashwani is Admin in Org 123
  • Ashwani is Manager in Org 456

We want Keycloak tokens to reflect this like:

"groups": [
  "/org-123/admin",
  "/org-456/manager"
  "/org-346/user"
]

Your Laravel app can then enforce access easily by parsing the groups claim.


Step 1 โ€” Create a Client Scope with Group Membership

Instead of configuring mappers directly on the client, the recommended approach is to use Client Scopes (reusable across clients).

  1. Go to Client Scopes
    • Left sidebar โ†’ Client scopes โ†’ Create client scope.
  2. Fill details
    • Name: wizbrand-groups
    • Type: Default
    • Protocol: openid-connect
    • Click Save.
  1. Add Group Membership mapper
    • Inside wizbrand-groups โ†’ Mappers tab โ†’ Add mapper.
    • Mapper Type: Group Membership
    • Name: groups
    • Token Claim Name: groups
    • Full group path: ON
    • Add to ID token: ON
    • Add to Access token: ON
    • (Optional) Add to Userinfo: ON (useful for debugging)
    • Click Save.

This ensures every token will carry group memberships like /org-123/admin.


Step 2 โ€” Attach the Client Scope to wizbrand-web

Now attach the scope to your Laravel client:

  1. Navigate to Clients
    • Left sidebar โ†’ Clients โ†’ select wizbrand-web.
  2. Open Client Scopes tab
    • Under Assigned Default Client Scopes, click Add client scope.
    • Select wizbrand-groups.
    • Assign as Default.

Now, every login via wizbrand-web automatically includes the groups claim.


Step 3 โ€” Model Organizations with Groups

Weโ€™ll use groups in Keycloak to represent organizations and roles.

  1. Create parent group for each organization
    • Left sidebar โ†’ Groups โ†’ New.
    • Example: /org-123
  2. Add subgroups for roles
    • Inside /org-123, create:
      • /org-123/admin
      • /org-123/manager
      • /org-123/user
  3. Assign users to the correct subgroup
    • Example:
      • Ashwani โ†’ /org-123/admin
      • Priya โ†’ /org-123/manager

Later, you can automate this with the Keycloak Admin REST API.


Step 4 โ€” Verify with Token Evaluation

  1. Go to:
    Clients โ†’ wizbrand-web โ†’ Client scopes โ†’ Evaluate.
  2. Choose a test user who belongs to a group (e.g., /professwiz/admin).
  3. Click Generate token โ†’ Decode the JSON.

You should see output like:

{
  "exp": 1758707369,
  "iat": 1758707069,
  "iss": "http://localhost:8080/realms/wizbrand",
  "aud": ["realm-management", "account"],
  "azp": "wizbrand-web",
  "name": "Ashwanik Kumar",
  "preferred_username": "ashwanik",
  "email": "ashwanik@ashwanik.com",
  "groups": [
    "/professwiz/admin"
  ]
}

โœ… groups claim is present and shows full path.

Mental model (single, consistent source of truth)

  • Organizations are groups
    Each org is a parent group in Keycloak: /org-{ORG_ID} (e.g., /org-123).
  • Roles are subgroups
    Under each org group, create fixed subgroups:
    /org-123/admin, /org-123/manager, /org-123/user.
  • Membership = authorization
    Put a user into one or more of those subgroups. Thatโ€™s your authorization state.
  • Tokens carry memberships
    The Group Membership protocol mapper projects these into the userโ€™s tokens as: "groups": [ "/org-123/admin", "/org-456/manager" ]
  • Laravel enforces
    Your app reads groups, derives (orgId, role) pairs, and gates UI + API access accordingly.

Result: No scattered role flags, no per-app duplication. Keycloak is the single source of truth for org/role membership.


Why โ€œfull group path = ONโ€ is crucial

Turning on Full group path ensures you donโ€™t just get admin, but the org context, e.g. /org-123/admin.
This solves two hard problems in one go:

  1. Multi-org users: You can tell which org a role belongs to.
  2. Name collisions: Every org has an admin subgroupโ€”paths keep them distinct.

Role semantics (keep it simple & predictable)

Define a clear, system-wide convention:

  • Admin: full CRUD on org resources; can invite/promote/demote members.
  • Manager: read + update on org resources; cannot manage members (or limit to specific domains, e.g., SEO panel).
  • User: read-only.

Tip: Keep the ladder strictly increasing (User โŸถ Manager โŸถ Admin). This makes role precedence trivial: if a user is both /org-123/user and /org-123/admin, treat them as Admin for org-123.


Parsing the groups claim (tiny, reliable helper)

Youโ€™ll see entries like:
/org-123/admin, /org-15/manager, /org-77/user

A tiny parser turns those into a map you can use everywhere.

Regex: ^/org-(\d+)/(admin|manager|user)$

PHP helper (pure function):

function parseOrgRolesFromGroups(array $groups): array {
    $orgRoles = []; // [orgId => highestRole]
    foreach ($groups as $g) {
        if (preg_match('#^/org-(\d+)/(admin|manager|user)$#', $g, $m)) {
            $orgId = (int)$m[1];
            $role  = $m[2];
            // precedence: admin > manager > user
            $rank = ['user'=>1, 'manager'=>2, 'admin'=>3];
            if (!isset($orgRoles[$orgId]) || $rank[$role] > $rank[$orgRoles[$orgId]]) {
                $orgRoles[$orgId] = $role;
            }
        }
    }
    return $orgRoles; // e.g., [123=>'admin', 456=>'manager']
}

Why this matters:

  • Works for any number of orgs.
  • Enforces role precedence deterministically.
  • Keeps controller/middleware logic clean.

Laravel integration (end-to-end)

A) Capture tokens at login (Socialite)

You already get tokens from the Socialite Keycloak driver:

$kcUser       = Socialite::driver('keycloak')->user();
$accessToken  = $kcUser->token;
$idToken      = $kcUser->accessTokenResponseBody['id_token'] ?? null;
$refreshToken = $kcUser->refreshToken ?? null;

// Store minimally & safely (session or encrypted store)
session([
  'kc_access_token'  => $accessToken,
  'kc_id_token'      => $idToken,
  'kc_refresh_token' => $refreshToken,
]);

Prefer using the ID token to read groups (itโ€™s meant for the client).
For backend APIs, validate Access token on each call if needed.

B) Decode token & cache groups

Use a lightweight JWT decode (no network) to read claims. You donโ€™t need to verify the signature for UI decisions if you trust your session boundaryโ€”but for API/critical actions, validate the token or call introspection.

function readGroupsFromIdToken(?string $idToken): array {
    if (!$idToken) return [];
    $parts = explode('.', $idToken);
    if (count($parts) !== 3) return [];
    $payload = json_decode(base64_decode(strtr($parts[1], '-_', '+/')), true);
    return $payload['groups'] ?? [];
}

Then parse & cache:

$groups  = readGroupsFromIdToken(session('kc_id_token'));
$orgMap  = parseOrgRolesFromGroups($groups);

// Cache for a short time to avoid re-parsing
cache()->put('orgRoles:'.auth()->id(), $orgMap, now()->addMinutes(10));

C) Middleware to guard org routes

Create EnsureOrgRole.php:

namespace App\Http\Middleware;

use Closure;
use Illuminate\Http\Request;

class EnsureOrgRole
{
    public function handle(Request $request, Closure $next, string $minRole)
    {
        $orgId = (int) $request->route('orgId'); // or from header/body, but prefer route param
        $orgRoles = cache()->remember('orgRoles:'.auth()->id(), 600, function () {
            $groups = readGroupsFromIdToken(session('kc_id_token'));
            return parseOrgRolesFromGroups($groups);
        });

        if (!isset($orgRoles[$orgId])) {
            abort(403, 'Not a member of this organization.');
        }

        $rank = ['user'=>1, 'manager'=>2, 'admin'=>3];
        $actual = $orgRoles[$orgId];

        if ($rank[$actual] < $rank[$minRole]) {
            abort(403, "Requires role {$minRole} or higher.");
        }

        // Optionally, attach resolved role for downstream use
        $request->attributes->set('orgRole', $actual);

        return $next($request);
    }
}

Register middleware alias in app/Http/Kernel.php:

'org.role' => \App\Http\Middleware\EnsureOrgRole::class,

Use it in routes:

Route::middleware(['auth', 'org.role:manager'])
     ->get('/orgs/{orgId}/projects', [ProjectController::class, 'index']);

Route::middleware(['auth', 'org.role:admin'])
     ->post('/orgs/{orgId}/members', [MemberController::class, 'invite']);

UI can also read orgRole from the request to enable/disable buttons, tabs, and actions.

D) Policies (optional, nice for model-level checks)

public function update(User $user, Project $project)
{
    $orgId   = $project->org_id;
    $orgRole = request()->attributes->get('orgRole') 
               ?? parseOrgRolesFromGroups(readGroupsFromIdToken(session('kc_id_token')))[$orgId] ?? null;

    return in_array($orgRole, ['manager','admin'], true);
}

Multitenancy hardening (donโ€™t trust client input)

  • Never trust orgId sent by the client to decide access. Always cross-check with the groups claim.
  • For data queries, filter by orgId after authorization (e.g., where('org_id', $orgId)), and never leak cross-org records.
  • If you pass tokens to microservices, have those services re-validate the Access token (either via JWKS or Keycloak introspection).

Lifecycle: invites, promotions, removals

  • Invite: On accept, add user to /org-123/user.
  • Promote: Move /org-123/user โ†’ /org-123/manager (or add both; the precedence logic still works).
  • Remove: Remove all /org-123/* for that user.

Changes reflect in new tokens. If you need near-instant enforcement, use short token TTL (e.g., 5โ€“15 min) and refresh on sensitive screens; or call token introspection on critical actions.


Admin API snippets (automation-friendly)

Create org + role subgroups

# Get token for admin user (confidential client flow or direct access grant for tooling)
KC_BASE="https://kc.example.com/realms/wizbrand"
TOKEN=$(curl -s -X POST "$KC_BASE/protocol/openid-connect/token" \
  -d grant_type=password -d client_id=admin-cli -d username=admin -d password=... \
  | jq -r .access_token)

# Create /org-123
ORG_JSON='{"name":"org-123"}'
ORG_ID=$(curl -s -D - -o /dev/null -X POST "https://kc.example.com/admin/realms/wizbrand/groups" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d "$ORG_JSON" | awk -F/ '/Location:/ {print $NF}')

# Create subgroups
for r in admin manager user; do
  curl -s -X POST "https://kc.example.com/admin/realms/wizbrand/groups/$ORG_ID/children" \
    -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
    -d "{\"name\":\"$r\"}"
done

Add a user to /org-123/admin

  1. Find subgroup ID for admin under org-123.
  2. PUT /admin/realms/{realm}/users/{userId}/groups/{groupId} with an empty body.

You can wrap this in your Org Service so developers never touch raw Keycloak APIs.


Performance & token size

  • A user in hundreds of orgs means many groups entries. That increases token size.
  • Mitigations:
    • Keep names compact (/org-123/admin is good).
    • Use Access tokens for API auth, store a compact org role map server-side (Redis) after first parse.
    • Consider โ€œlazyโ€ authorization for non-critical pages (resolve on demand).

Security checklist

  • Confidential client for server-side apps; keep client secret safe.
  • Short token TTL + refresh tokens for better revocation latency.
  • Enable HTTPS everywhere (no mixed content).
  • Validate JWT signatures on backend services (JWKS).
  • Rate-limit sensitive endpoints (invites, promotions).
  • Log who changed which membership and when.

Testing matrix (copy/paste into QA doc)

  • User in one org as user โ†’ can view, cannot update.
  • User promoted to manager โ†’ can update, cannot manage members.
  • User promoted to admin โ†’ full CRUD + member management.
  • User in two orgs (admin in 123, user in 456) โ†’ admin powers only in 123.
  • User removed from org โ†’ loses access after token refresh / re-login.
  • Token expires while active โ†’ refresh works, groups remain consistent.
  • API call with tampered orgId โ†’ blocked by middleware.
  • Very large memberships โ†’ app still responsive (caching is effective).

Common pitfalls & fixes

  • โ€œGroups not in tokenโ€
    Ensure the Client Scope with the Group Membership mapper is attached as Default to wizbrand-web.
  • โ€œOnly role names, no org pathโ€
    You forgot Full group path: ON in the mapper.
  • โ€œChange in KC not reflectedโ€
    Token still valid. Use shorter TTL, or refresh token after admin changes.
  • โ€œGot redirected to master realmโ€
    Double-check Socialite Keycloak config: realm must be wizbrand and the issuer/redirect URLs must point to the right realm paths.

Putting it all together (the 5-step recipe)

  1. Keycloak groups: /org-{id} โ†’ admin|manager|user.
  2. Client scope: wizbrand-groups with Group Membership mapper (Full path: ON, added to ID & Access tokens).
  3. Attach scope to wizbrand-web as Default.
  4. Parse groups in Laravel with a tiny helper to get [orgId => role] with precedence.
  5. Enforce with middleware and (optionally) policies; cache the parsed map for speed.

Leave a Reply

Your email address will not be published. Required fields are marked *

0
Would love your thoughts, please comment.x
()
x