目的
「権限情報をソースコードにべた書きしたくない」「Keycloakの設定変更だけで権限を管理したい」そんな要望を実現するため、Keycloak Authorization Servicesを使い、権限制御を動的に行いました。
ハードコーディングされた権限制御から脱却し、keycloakでの権限制御を目指します。
AsIs
// ハードコーディングされた権限制御
const permissions = {
canViewUsers: userRoles.includes('manager') || userRoles.includes('admin'),
canViewSales: userRoles.includes('manager') || userRoles.includes('admin'),
canViewAdmin: userRoles.includes('admin')
};
ToBe
// 権限情報をkeycloackから取得。リソース名から権限をマッピング
permissions.canViewUsers = await evaluatePermission(accessToken, 'User Management Pages');
permissions.canViewUsers = await evaluatePermission(accessToken, 'Sales Management Pages');
permissions.canViewUsers = await evaluatePermission(accessToken, 'Admin Management Pages');
権限情報をkeycloakのpermissionで管理
今回実装したアーキテクチャの概要
認証用のPublicClientと、認可用のConfidentialClientの2つのclientを使用しました。

実装
frontend-app Client(Public Client)
{
"clientId": "frontend-app",
"publicClient": true,
"standardFlowEnabled": true,
"directAccessGrantsEnabled": false,
"redirectUris": ["http://localhost:3000/*"],
"webOrigins": ["http://localhost:3000"]
}
backend-service Client(Confidential Client)
{
"clientId": "backend-service",
"publicClient": false,
"serviceAccountsEnabled": true,
"authorizationServicesEnabled": true,
"authorizationSettings": {
"resources": [
{
"name": "User Management Pages",
"scopes": ["access"]
},
{
"name": "Sales Management Pages",
"scopes": ["access"]
},
{
"name": "Admin Management Pages",
"scopes": ["access"]
}
,
{
"name": "Report Management Pages",
"scopes": ["access"]
}
],
"policies": [
{
"name": "Admin Role Policy",
"type": "role",
"logic": "POSITIVE",
"config": {
"roles": "[{\"id\":\"admin\",\"required\":false}]"
}
},
{
"name": "Manager Role Policy",
"type": "role",
"logic": "POSITIVE",
"config": {
"roles": "[{\"id\":\"manager\",\"required\":false}]"
}
},
{
"name": "User Role Policy",
"type": "role",
"logic": "POSITIVE",
"config": {
"roles": "[{\"id\":\"user\",\"required\":false}]"
}
}
]
}
}
Permissonsの設定
フロントエンド
// App.js
import React, { useState, useEffect } from 'react';
import keycloak from './keycloak';
function App() {
const [user, setUser] = useState(null);
const [permissions, setPermissions] = useState(null);
useEffect(() => {
keycloak.init({
onLoad: 'check-sso',
checkLoginIframe: false,
pkceMethod: 'S256'
})
.then(authenticated => {
if (authenticated && keycloak.token) {
fetchUserAndPermissions();
}
});
}, []);
const fetchUserAndPermissions = async () => {
try {
// ユーザー情報取得
const userResponse = await fetch('http://localhost:8081/api/user', {
headers: {
'Authorization': `Bearer ${keycloak.token}`
}
});
const userData = await userResponse.json();
setUser(userData);
// 権限情報をバックエンドから取得
const permResponse = await fetch('http://localhost:8081/api/permissions', {
headers: {
'Authorization': `Bearer ${keycloak.token}`
}
});
const permData = await permResponse.json();
setPermissions(permData);
} catch (error) {
// エラーハンドリング
}
};
if (!keycloak.authenticated) {
return (
<div>
<h2>ログインが必要です</h2>
<button onClick={() => keycloak.login()}>
Keycloakでログイン
</button>
</div>
);
}
return (
<div>
<h1>Keycloak 権限制御デモ</h1>
{/* ユーザー情報表示 */}
<div>
<h3>ユーザー情報</h3>
<p>ユーザー名: {user?.username}</p>
<p>ロール: {user?.roles?.join(', ')}</p>
<button onClick={() => keycloak.logout()}>ログアウト</button>
</div>
{/* 権限に基づく画面表示制御 */}
{permissions && (
<div>
<h3>画面表示権限</h3>
{permissions.canViewUsers && (
<div>ユーザー管理画面</div>
)}
{permissions.canViewSales && (
<div>営業管理画面</div>
)}
{permissions.canViewAdmin && (
<div>管理者設定画面</div>
)}
{permissions.canViewReport && (
<div>レポート画面</div>
)}
</div>
)}
</div>
);
}
export default App;
Node.js Backend
const express = require('express');
const cors = require('cors');
const app = express();
app.use(cors({
origin: 'http://localhost:3000',
credentials: true
}));
app.use(express.json());
// JWT認証
function verifyToken(req, res, next) {
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return res.status(401).json({ error: 'No token provided' });
}
const token = authHeader.split(' ')[1];
try {
const payload = JSON.parse(Buffer.from(token.split('.')[1], 'base64').toString());
req.user = payload;
next();
} catch (error) {
return res.status(401).json({ error: 'Invalid token' });
}
}
// ユーザー情報取得API
app.get('/api/user', verifyToken, (req, res) => {
res.json({
username: req.user.preferred_username || req.user.sub,
roles: req.user.realm_access?.roles || []
});
});
// Keycloak Authorization Servicesによる権限チェックAPI
app.get('/api/permissions', verifyToken, async (req, res) => {
try {
const accessToken = req.headers.authorization.split(' ')[1];
// Admin APIトークン取得
const adminToken = await getAdminToken();
// Keycloakからリソース情報を取得
const resources = await getResourcesFromKeycloak(adminToken);
// 各リソースに対する権限を評価
const permissions = {};
for (const resource of resources) {
try {
const hasPermission = await evaluatePermission(accessToken, resource.name);
// 権限情報をkeycloackから取得。リソース名から権限をマッピング
if (resource.name === 'User Management Pages') {
permissions.canViewUsers = hasPermission;
} else if (resource.name === 'Sales Management Pages') {
permissions.canViewSales = hasPermission;
} else if (resource.name === 'Admin Management Pages') {
permissions.canViewAdmin = hasPermission;
}else if (resource.name === 'Report Management Pages') {
permissions.canViewReport = hasPermission;
}
} catch (evalError) {
// 評価失敗時は権限なしとする
if (resource.name === 'User Management Pages') permissions.canViewUsers = false;
if (resource.name === 'Sales Management Pages') permissions.canViewSales = false;
if (resource.name === 'Admin Management Pages') permissions.canViewAdmin = false;
if (resource.name === 'Report Management Pages') permissions.canViewReport = false;
}
}
res.json(permissions);
} catch (error) {
res.json({
canViewUsers: "false",
canViewSales: "false",
canViewAdmin: "false",
canViewReport: "false",
});
}
});
// Admin APIトークン取得
async function getAdminToken() {
const response = await fetch('http://localhost:8080/realms/master/protocol/openid-connect/token', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded'
},
body: new URLSearchParams({
grant_type: 'password',
client_id: 'admin-cli',
username: 'admin',
password: 'admin'
})
});
if (!response.ok) {
throw new Error(`Admin token request failed: ${response.status}`);
}
const data = await response.json();
return data.access_token;
}
// Keycloakからリソース情報を取得
async function getResourcesFromKeycloak(adminToken) {
// backend-serviceクライアントIDを取得
const clientsResponse = await fetch('http://localhost:8080/admin/realms/demo/clients', {
headers: {
'Authorization': `Bearer ${adminToken}`,
'Content-Type': 'application/json'
}
});
if (!clientsResponse.ok) {
throw new Error(`Clients request failed: ${clientsResponse.status}`);
}
const clients = await clientsResponse.json();
const backendClient = clients.find(c => c.clientId === 'backend-service');
if (!backendClient) {
throw new Error('backend-service client not found');
}
// リソース情報を取得
const response = await fetch(`http://localhost:8080/admin/realms/demo/clients/${backendClient.id}/authz/resource-server/resource`, {
headers: {
'Authorization': `Bearer ${adminToken}`,
'Content-Type': 'application/json'
}
});
if (!response.ok) {
throw new Error(`Resources request failed: ${response.status}`);
}
return await response.json();
}
// UMA-2.0による権限評価
async function evaluatePermission(accessToken, resourceName) {
const response = await fetch('http://localhost:8080/realms/demo/protocol/openid-connect/token', {
method: 'POST',
headers: {
'Authorization': `Bearer ${accessToken}`,
'Content-Type': 'application/x-www-form-urlencoded'
},
body: new URLSearchParams({
'grant_type': 'urn:ietf:params:oauth:grant-type:uma-ticket',
'audience': 'backend-service',
'permission': `${resourceName}#access`
})
});
return response.ok;
}
const PORT = 8081;
app.listen(PORT);
動作確認
権限追加
新たに、レポート画面だけを見る権限を追加する要件が出てきたとします。
権限の強さは、admin > manager > user > viewer とします。
ポリシーの作成を行い、admin,manager,user,viewerと紐づけておきます。

パーミッションの変更を行い、ポリシーをViewer Policyに変更します。

これで設定が完了しました。
動作確認
無事、keycloakの設定変更のみで画面の表示制御をすることができました。











