keycloak-mcp 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- keycloak_mcp-0.1.0/LICENSE +21 -0
- keycloak_mcp-0.1.0/PKG-INFO +193 -0
- keycloak_mcp-0.1.0/README.md +168 -0
- keycloak_mcp-0.1.0/keycloak_mcp/__init__.py +3 -0
- keycloak_mcp-0.1.0/keycloak_mcp/__main__.py +5 -0
- keycloak_mcp-0.1.0/keycloak_mcp/auth.py +58 -0
- keycloak_mcp-0.1.0/keycloak_mcp/client.py +163 -0
- keycloak_mcp-0.1.0/keycloak_mcp/server.py +499 -0
- keycloak_mcp-0.1.0/keycloak_mcp.egg-info/PKG-INFO +193 -0
- keycloak_mcp-0.1.0/keycloak_mcp.egg-info/SOURCES.txt +16 -0
- keycloak_mcp-0.1.0/keycloak_mcp.egg-info/dependency_links.txt +1 -0
- keycloak_mcp-0.1.0/keycloak_mcp.egg-info/entry_points.txt +2 -0
- keycloak_mcp-0.1.0/keycloak_mcp.egg-info/requires.txt +2 -0
- keycloak_mcp-0.1.0/keycloak_mcp.egg-info/top_level.txt +1 -0
- keycloak_mcp-0.1.0/pyproject.toml +61 -0
- keycloak_mcp-0.1.0/setup.cfg +4 -0
- keycloak_mcp-0.1.0/tests/test_client.py +181 -0
- keycloak_mcp-0.1.0/tests/test_server.py +277 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 AIKAWA Shigechika
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: keycloak-mcp
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: MCP server for KeyCloak Admin REST API via Service Account
|
|
5
|
+
Author: AIKAWA Shigechika
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/shigechika/keycloak-mcp
|
|
8
|
+
Project-URL: Repository, https://github.com/shigechika/keycloak-mcp
|
|
9
|
+
Project-URL: Issues, https://github.com/shigechika/keycloak-mcp/issues
|
|
10
|
+
Keywords: keycloak,mcp,model-context-protocol,sso,authentication
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: Intended Audience :: System Administrators
|
|
13
|
+
Classifier: Operating System :: OS Independent
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
18
|
+
Classifier: Topic :: System :: Systems Administration
|
|
19
|
+
Requires-Python: >=3.10
|
|
20
|
+
Description-Content-Type: text/markdown
|
|
21
|
+
License-File: LICENSE
|
|
22
|
+
Requires-Dist: mcp>=1.0
|
|
23
|
+
Requires-Dist: httpx
|
|
24
|
+
Dynamic: license-file
|
|
25
|
+
|
|
26
|
+
<!-- mcp-name: io.github.shigechika/keycloak-mcp -->
|
|
27
|
+
|
|
28
|
+
# keycloak-mcp
|
|
29
|
+
|
|
30
|
+
English | [日本語](README.ja.md)
|
|
31
|
+
|
|
32
|
+
MCP (Model Context Protocol) server for [KeyCloak](https://www.keycloak.org/) Admin REST API.
|
|
33
|
+
|
|
34
|
+
Uses **Client Credentials Grant** (Service Account) — no user password or TOTP required.
|
|
35
|
+
Infinispan-safe: does not create user sessions or use the userinfo endpoint.
|
|
36
|
+
|
|
37
|
+
## Features
|
|
38
|
+
|
|
39
|
+
### User Management
|
|
40
|
+
|
|
41
|
+
| Tool | Description |
|
|
42
|
+
|------|-------------|
|
|
43
|
+
| `count_users` | Get total user count in the realm |
|
|
44
|
+
| `search_users` | Search users by username, email, or name |
|
|
45
|
+
| `get_user` | Get detailed user information by username |
|
|
46
|
+
| `reset_password` | Reset a user's password |
|
|
47
|
+
| `reset_passwords_batch` | Reset passwords for multiple users from CSV |
|
|
48
|
+
| `get_user_sessions` | Get active sessions for a user |
|
|
49
|
+
|
|
50
|
+
### Group Management
|
|
51
|
+
|
|
52
|
+
| Tool | Description |
|
|
53
|
+
|------|-------------|
|
|
54
|
+
| `list_user_groups` | List groups a user belongs to |
|
|
55
|
+
| `list_users_by_group` | List all members of a group |
|
|
56
|
+
|
|
57
|
+
### Security Monitoring
|
|
58
|
+
|
|
59
|
+
| Tool | Description |
|
|
60
|
+
|------|-------------|
|
|
61
|
+
| `get_brute_force_status` | Check if a user is locked by brute force detection |
|
|
62
|
+
| `get_login_failures_by_ip` | Login failure statistics by source IP |
|
|
63
|
+
|
|
64
|
+
### Event Analytics
|
|
65
|
+
|
|
66
|
+
| Tool | Description |
|
|
67
|
+
|------|-------------|
|
|
68
|
+
| `get_events` | Get KeyCloak events with filters (type, user, date) |
|
|
69
|
+
| `get_login_stats` | Login success/failure statistics with pagination |
|
|
70
|
+
| `get_login_stats_by_hour` | Login statistics by hour (local time) |
|
|
71
|
+
| `get_login_stats_by_client` | Login statistics by client (SP) |
|
|
72
|
+
| `get_password_update_events` | Password update event history |
|
|
73
|
+
|
|
74
|
+
### Session & Client
|
|
75
|
+
|
|
76
|
+
| Tool | Description |
|
|
77
|
+
|------|-------------|
|
|
78
|
+
| `get_session_stats` | Active session count per client |
|
|
79
|
+
| `get_client_sessions` | Active sessions for a specific client |
|
|
80
|
+
| `list_clients` | List all SAML/OIDC clients |
|
|
81
|
+
| `get_realm_roles` | List all realm-level roles |
|
|
82
|
+
|
|
83
|
+
## Setup
|
|
84
|
+
|
|
85
|
+
```bash
|
|
86
|
+
# uv
|
|
87
|
+
uv pip install keycloak-mcp
|
|
88
|
+
|
|
89
|
+
# pip
|
|
90
|
+
pip install keycloak-mcp
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
Or from source:
|
|
94
|
+
|
|
95
|
+
```bash
|
|
96
|
+
git clone https://github.com/shigechika/keycloak-mcp.git
|
|
97
|
+
cd keycloak-mcp
|
|
98
|
+
|
|
99
|
+
# uv
|
|
100
|
+
uv sync
|
|
101
|
+
|
|
102
|
+
# pip
|
|
103
|
+
pip install -e .
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
## Configuration
|
|
107
|
+
|
|
108
|
+
Set the following environment variables:
|
|
109
|
+
|
|
110
|
+
| Variable | Description | Default |
|
|
111
|
+
|---|---|---|
|
|
112
|
+
| `KEYCLOAK_URL` | KeyCloak base URL (e.g., `https://sso.example.com`) | *required* |
|
|
113
|
+
| `KEYCLOAK_REALM` | Realm name | `master` |
|
|
114
|
+
| `KEYCLOAK_CLIENT_ID` | Service Account client ID | *required* |
|
|
115
|
+
| `KEYCLOAK_CLIENT_SECRET` | Client secret | *required* |
|
|
116
|
+
|
|
117
|
+
### KeyCloak Client Setup
|
|
118
|
+
|
|
119
|
+
1. Create a new client in KeyCloak Admin Console
|
|
120
|
+
2. Enable **Client authentication** and **Service account roles**
|
|
121
|
+
3. Assign realm roles: `view-users`, `view-events`, `view-clients`, `manage-users` (for password reset)
|
|
122
|
+
|
|
123
|
+
## Usage
|
|
124
|
+
|
|
125
|
+
### Claude Code
|
|
126
|
+
|
|
127
|
+
Add to `.mcp.json`:
|
|
128
|
+
|
|
129
|
+
```json
|
|
130
|
+
{
|
|
131
|
+
"mcpServers": {
|
|
132
|
+
"keycloak-mcp": {
|
|
133
|
+
"type": "stdio",
|
|
134
|
+
"command": "keycloak-mcp",
|
|
135
|
+
"env": {
|
|
136
|
+
"KEYCLOAK_URL": "https://sso.example.com",
|
|
137
|
+
"KEYCLOAK_CLIENT_ID": "keycloak-mcp",
|
|
138
|
+
"KEYCLOAK_CLIENT_SECRET": ""
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
### Claude Desktop
|
|
146
|
+
|
|
147
|
+
Add to `claude_desktop_config.json`:
|
|
148
|
+
|
|
149
|
+
```json
|
|
150
|
+
{
|
|
151
|
+
"mcpServers": {
|
|
152
|
+
"keycloak-mcp": {
|
|
153
|
+
"command": "keycloak-mcp",
|
|
154
|
+
"env": {
|
|
155
|
+
"KEYCLOAK_URL": "https://sso.example.com",
|
|
156
|
+
"KEYCLOAK_CLIENT_ID": "keycloak-mcp",
|
|
157
|
+
"KEYCLOAK_CLIENT_SECRET": ""
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
### Direct Execution
|
|
165
|
+
|
|
166
|
+
```bash
|
|
167
|
+
export KEYCLOAK_URL=https://sso.example.com
|
|
168
|
+
export KEYCLOAK_CLIENT_ID=keycloak-mcp
|
|
169
|
+
export KEYCLOAK_CLIENT_SECRET=your-secret
|
|
170
|
+
keycloak-mcp
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
## Development
|
|
174
|
+
|
|
175
|
+
```bash
|
|
176
|
+
git clone https://github.com/shigechika/keycloak-mcp.git
|
|
177
|
+
cd keycloak-mcp
|
|
178
|
+
|
|
179
|
+
# uv
|
|
180
|
+
uv sync --dev
|
|
181
|
+
uv run pytest -v
|
|
182
|
+
uv run ruff check .
|
|
183
|
+
|
|
184
|
+
# pip
|
|
185
|
+
python3 -m venv .venv
|
|
186
|
+
.venv/bin/pip install -e . && .venv/bin/pip install pytest pytest-cov respx ruff
|
|
187
|
+
.venv/bin/pytest -v
|
|
188
|
+
.venv/bin/ruff check .
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
## License
|
|
192
|
+
|
|
193
|
+
MIT
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
<!-- mcp-name: io.github.shigechika/keycloak-mcp -->
|
|
2
|
+
|
|
3
|
+
# keycloak-mcp
|
|
4
|
+
|
|
5
|
+
English | [日本語](README.ja.md)
|
|
6
|
+
|
|
7
|
+
MCP (Model Context Protocol) server for [KeyCloak](https://www.keycloak.org/) Admin REST API.
|
|
8
|
+
|
|
9
|
+
Uses **Client Credentials Grant** (Service Account) — no user password or TOTP required.
|
|
10
|
+
Infinispan-safe: does not create user sessions or use the userinfo endpoint.
|
|
11
|
+
|
|
12
|
+
## Features
|
|
13
|
+
|
|
14
|
+
### User Management
|
|
15
|
+
|
|
16
|
+
| Tool | Description |
|
|
17
|
+
|------|-------------|
|
|
18
|
+
| `count_users` | Get total user count in the realm |
|
|
19
|
+
| `search_users` | Search users by username, email, or name |
|
|
20
|
+
| `get_user` | Get detailed user information by username |
|
|
21
|
+
| `reset_password` | Reset a user's password |
|
|
22
|
+
| `reset_passwords_batch` | Reset passwords for multiple users from CSV |
|
|
23
|
+
| `get_user_sessions` | Get active sessions for a user |
|
|
24
|
+
|
|
25
|
+
### Group Management
|
|
26
|
+
|
|
27
|
+
| Tool | Description |
|
|
28
|
+
|------|-------------|
|
|
29
|
+
| `list_user_groups` | List groups a user belongs to |
|
|
30
|
+
| `list_users_by_group` | List all members of a group |
|
|
31
|
+
|
|
32
|
+
### Security Monitoring
|
|
33
|
+
|
|
34
|
+
| Tool | Description |
|
|
35
|
+
|------|-------------|
|
|
36
|
+
| `get_brute_force_status` | Check if a user is locked by brute force detection |
|
|
37
|
+
| `get_login_failures_by_ip` | Login failure statistics by source IP |
|
|
38
|
+
|
|
39
|
+
### Event Analytics
|
|
40
|
+
|
|
41
|
+
| Tool | Description |
|
|
42
|
+
|------|-------------|
|
|
43
|
+
| `get_events` | Get KeyCloak events with filters (type, user, date) |
|
|
44
|
+
| `get_login_stats` | Login success/failure statistics with pagination |
|
|
45
|
+
| `get_login_stats_by_hour` | Login statistics by hour (local time) |
|
|
46
|
+
| `get_login_stats_by_client` | Login statistics by client (SP) |
|
|
47
|
+
| `get_password_update_events` | Password update event history |
|
|
48
|
+
|
|
49
|
+
### Session & Client
|
|
50
|
+
|
|
51
|
+
| Tool | Description |
|
|
52
|
+
|------|-------------|
|
|
53
|
+
| `get_session_stats` | Active session count per client |
|
|
54
|
+
| `get_client_sessions` | Active sessions for a specific client |
|
|
55
|
+
| `list_clients` | List all SAML/OIDC clients |
|
|
56
|
+
| `get_realm_roles` | List all realm-level roles |
|
|
57
|
+
|
|
58
|
+
## Setup
|
|
59
|
+
|
|
60
|
+
```bash
|
|
61
|
+
# uv
|
|
62
|
+
uv pip install keycloak-mcp
|
|
63
|
+
|
|
64
|
+
# pip
|
|
65
|
+
pip install keycloak-mcp
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
Or from source:
|
|
69
|
+
|
|
70
|
+
```bash
|
|
71
|
+
git clone https://github.com/shigechika/keycloak-mcp.git
|
|
72
|
+
cd keycloak-mcp
|
|
73
|
+
|
|
74
|
+
# uv
|
|
75
|
+
uv sync
|
|
76
|
+
|
|
77
|
+
# pip
|
|
78
|
+
pip install -e .
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
## Configuration
|
|
82
|
+
|
|
83
|
+
Set the following environment variables:
|
|
84
|
+
|
|
85
|
+
| Variable | Description | Default |
|
|
86
|
+
|---|---|---|
|
|
87
|
+
| `KEYCLOAK_URL` | KeyCloak base URL (e.g., `https://sso.example.com`) | *required* |
|
|
88
|
+
| `KEYCLOAK_REALM` | Realm name | `master` |
|
|
89
|
+
| `KEYCLOAK_CLIENT_ID` | Service Account client ID | *required* |
|
|
90
|
+
| `KEYCLOAK_CLIENT_SECRET` | Client secret | *required* |
|
|
91
|
+
|
|
92
|
+
### KeyCloak Client Setup
|
|
93
|
+
|
|
94
|
+
1. Create a new client in KeyCloak Admin Console
|
|
95
|
+
2. Enable **Client authentication** and **Service account roles**
|
|
96
|
+
3. Assign realm roles: `view-users`, `view-events`, `view-clients`, `manage-users` (for password reset)
|
|
97
|
+
|
|
98
|
+
## Usage
|
|
99
|
+
|
|
100
|
+
### Claude Code
|
|
101
|
+
|
|
102
|
+
Add to `.mcp.json`:
|
|
103
|
+
|
|
104
|
+
```json
|
|
105
|
+
{
|
|
106
|
+
"mcpServers": {
|
|
107
|
+
"keycloak-mcp": {
|
|
108
|
+
"type": "stdio",
|
|
109
|
+
"command": "keycloak-mcp",
|
|
110
|
+
"env": {
|
|
111
|
+
"KEYCLOAK_URL": "https://sso.example.com",
|
|
112
|
+
"KEYCLOAK_CLIENT_ID": "keycloak-mcp",
|
|
113
|
+
"KEYCLOAK_CLIENT_SECRET": ""
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
### Claude Desktop
|
|
121
|
+
|
|
122
|
+
Add to `claude_desktop_config.json`:
|
|
123
|
+
|
|
124
|
+
```json
|
|
125
|
+
{
|
|
126
|
+
"mcpServers": {
|
|
127
|
+
"keycloak-mcp": {
|
|
128
|
+
"command": "keycloak-mcp",
|
|
129
|
+
"env": {
|
|
130
|
+
"KEYCLOAK_URL": "https://sso.example.com",
|
|
131
|
+
"KEYCLOAK_CLIENT_ID": "keycloak-mcp",
|
|
132
|
+
"KEYCLOAK_CLIENT_SECRET": ""
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
### Direct Execution
|
|
140
|
+
|
|
141
|
+
```bash
|
|
142
|
+
export KEYCLOAK_URL=https://sso.example.com
|
|
143
|
+
export KEYCLOAK_CLIENT_ID=keycloak-mcp
|
|
144
|
+
export KEYCLOAK_CLIENT_SECRET=your-secret
|
|
145
|
+
keycloak-mcp
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
## Development
|
|
149
|
+
|
|
150
|
+
```bash
|
|
151
|
+
git clone https://github.com/shigechika/keycloak-mcp.git
|
|
152
|
+
cd keycloak-mcp
|
|
153
|
+
|
|
154
|
+
# uv
|
|
155
|
+
uv sync --dev
|
|
156
|
+
uv run pytest -v
|
|
157
|
+
uv run ruff check .
|
|
158
|
+
|
|
159
|
+
# pip
|
|
160
|
+
python3 -m venv .venv
|
|
161
|
+
.venv/bin/pip install -e . && .venv/bin/pip install pytest pytest-cov respx ruff
|
|
162
|
+
.venv/bin/pytest -v
|
|
163
|
+
.venv/bin/ruff check .
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
## License
|
|
167
|
+
|
|
168
|
+
MIT
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""Token management for KeyCloak Service Account (Client Credentials Grant)."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import time
|
|
5
|
+
|
|
6
|
+
import httpx
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class TokenManager:
|
|
10
|
+
"""Manage KeyCloak access tokens with automatic refresh.
|
|
11
|
+
|
|
12
|
+
Tokens are refreshed 30 seconds before expiry to avoid mid-request failures.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
def __init__(self):
|
|
16
|
+
self.url = os.environ["KEYCLOAK_URL"].rstrip("/")
|
|
17
|
+
self.realm = os.environ.get("KEYCLOAK_REALM", "master")
|
|
18
|
+
self.client_id = os.environ["KEYCLOAK_CLIENT_ID"]
|
|
19
|
+
self.client_secret = os.environ["KEYCLOAK_CLIENT_SECRET"]
|
|
20
|
+
self._token = None
|
|
21
|
+
self._expires_at = 0
|
|
22
|
+
|
|
23
|
+
@property
|
|
24
|
+
def token_endpoint(self) -> str:
|
|
25
|
+
"""Return the OIDC token endpoint URL."""
|
|
26
|
+
return f"{self.url}/realms/{self.realm}/protocol/openid-connect/token"
|
|
27
|
+
|
|
28
|
+
@property
|
|
29
|
+
def admin_base(self) -> str:
|
|
30
|
+
"""Return the Admin REST API base URL."""
|
|
31
|
+
return f"{self.url}/admin/realms/{self.realm}"
|
|
32
|
+
|
|
33
|
+
def get_token(self) -> str:
|
|
34
|
+
"""Return a valid access token, refreshing if needed."""
|
|
35
|
+
if self._token and time.time() < self._expires_at - 30:
|
|
36
|
+
return self._token
|
|
37
|
+
return self._refresh()
|
|
38
|
+
|
|
39
|
+
def _refresh(self) -> str:
|
|
40
|
+
"""Fetch a new token via Client Credentials Grant."""
|
|
41
|
+
resp = httpx.post(
|
|
42
|
+
self.token_endpoint,
|
|
43
|
+
data={
|
|
44
|
+
"grant_type": "client_credentials",
|
|
45
|
+
"client_id": self.client_id,
|
|
46
|
+
"client_secret": self.client_secret,
|
|
47
|
+
},
|
|
48
|
+
timeout=10,
|
|
49
|
+
)
|
|
50
|
+
resp.raise_for_status()
|
|
51
|
+
data = resp.json()
|
|
52
|
+
self._token = data["access_token"]
|
|
53
|
+
self._expires_at = time.time() + data.get("expires_in", 300)
|
|
54
|
+
return self._token
|
|
55
|
+
|
|
56
|
+
def headers(self) -> dict:
|
|
57
|
+
"""Return Authorization headers with a valid Bearer token."""
|
|
58
|
+
return {"Authorization": f"Bearer {self.get_token()}"}
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
"""KeyCloak Admin REST API client."""
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
import httpx
|
|
6
|
+
|
|
7
|
+
from .auth import TokenManager
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class KeyCloakClient:
|
|
11
|
+
"""Thin wrapper around the KeyCloak Admin REST API."""
|
|
12
|
+
|
|
13
|
+
def __init__(self):
|
|
14
|
+
self.auth = TokenManager()
|
|
15
|
+
self._http = httpx.Client(timeout=30)
|
|
16
|
+
|
|
17
|
+
def _get(self, path: str, params: dict | None = None) -> Any:
|
|
18
|
+
"""GET request to Admin API."""
|
|
19
|
+
url = f"{self.auth.admin_base}{path}"
|
|
20
|
+
resp = self._http.get(url, headers=self.auth.headers(), params=params or {})
|
|
21
|
+
resp.raise_for_status()
|
|
22
|
+
return resp.json()
|
|
23
|
+
|
|
24
|
+
def _put(self, path: str, json: dict | None = None) -> int:
|
|
25
|
+
"""PUT request to Admin API. Returns status code."""
|
|
26
|
+
url = f"{self.auth.admin_base}{path}"
|
|
27
|
+
resp = self._http.put(url, headers=self.auth.headers(), json=json or {})
|
|
28
|
+
resp.raise_for_status()
|
|
29
|
+
return resp.status_code
|
|
30
|
+
|
|
31
|
+
# --- Users ---
|
|
32
|
+
|
|
33
|
+
def count_users(self) -> int:
|
|
34
|
+
"""Return total user count."""
|
|
35
|
+
return self._get("/users/count")
|
|
36
|
+
|
|
37
|
+
def search_users(self, query: str, max_results: int = 20) -> list[dict]:
|
|
38
|
+
"""Search users by username, email, or name."""
|
|
39
|
+
return self._get("/users", {"search": query, "max": max_results})
|
|
40
|
+
|
|
41
|
+
def get_user(self, user_id: str) -> dict:
|
|
42
|
+
"""Get user by ID."""
|
|
43
|
+
return self._get(f"/users/{user_id}")
|
|
44
|
+
|
|
45
|
+
def get_user_by_username(self, username: str) -> dict | None:
|
|
46
|
+
"""Get user by exact username. Returns None if not found."""
|
|
47
|
+
users = self._get("/users", {"username": username, "exact": "true"})
|
|
48
|
+
return users[0] if users else None
|
|
49
|
+
|
|
50
|
+
def reset_password(self, user_id: str, password: str, temporary: bool = False) -> int:
|
|
51
|
+
"""Reset a user's password."""
|
|
52
|
+
return self._put(
|
|
53
|
+
f"/users/{user_id}/reset-password",
|
|
54
|
+
{"type": "password", "value": password, "temporary": temporary},
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
def get_user_sessions(self, user_id: str) -> list[dict]:
|
|
58
|
+
"""Get active sessions for a user."""
|
|
59
|
+
return self._get(f"/users/{user_id}/sessions")
|
|
60
|
+
|
|
61
|
+
def get_user_roles(self, user_id: str) -> dict:
|
|
62
|
+
"""Get role mappings for a user."""
|
|
63
|
+
return self._get(f"/users/{user_id}/role-mappings")
|
|
64
|
+
|
|
65
|
+
def get_user_groups(self, user_id: str) -> list[dict]:
|
|
66
|
+
"""Get groups a user belongs to."""
|
|
67
|
+
return self._get(f"/users/{user_id}/groups")
|
|
68
|
+
|
|
69
|
+
# --- Brute Force ---
|
|
70
|
+
|
|
71
|
+
def get_brute_force_status(self, user_id: str) -> dict:
|
|
72
|
+
"""Get brute force detection status for a user."""
|
|
73
|
+
return self._get(f"/attack-detection/brute-force/users/{user_id}")
|
|
74
|
+
|
|
75
|
+
# --- Groups ---
|
|
76
|
+
|
|
77
|
+
def list_groups(self, max_results: int = 100) -> list[dict]:
|
|
78
|
+
"""List all groups."""
|
|
79
|
+
return self._get("/groups", {"max": max_results})
|
|
80
|
+
|
|
81
|
+
def get_group_members(self, group_id: str, max_results: int = 100) -> list[dict]:
|
|
82
|
+
"""Get members of a group."""
|
|
83
|
+
return self._get(f"/groups/{group_id}/members", {"max": max_results})
|
|
84
|
+
|
|
85
|
+
# --- Events ---
|
|
86
|
+
|
|
87
|
+
def get_events(
|
|
88
|
+
self,
|
|
89
|
+
event_type: str | None = None,
|
|
90
|
+
user: str | None = None,
|
|
91
|
+
date_from: str | None = None,
|
|
92
|
+
date_to: str | None = None,
|
|
93
|
+
max_results: int = 100,
|
|
94
|
+
) -> list[dict]:
|
|
95
|
+
"""Get events with optional filters (single page)."""
|
|
96
|
+
params: dict[str, Any] = {"max": max_results}
|
|
97
|
+
if event_type:
|
|
98
|
+
params["type"] = event_type
|
|
99
|
+
if user:
|
|
100
|
+
params["user"] = user
|
|
101
|
+
if date_from:
|
|
102
|
+
params["dateFrom"] = date_from
|
|
103
|
+
if date_to:
|
|
104
|
+
params["dateTo"] = date_to
|
|
105
|
+
return self._get("/events", params)
|
|
106
|
+
|
|
107
|
+
def get_events_all(
|
|
108
|
+
self,
|
|
109
|
+
event_type: str | None = None,
|
|
110
|
+
user: str | None = None,
|
|
111
|
+
date_from: str | None = None,
|
|
112
|
+
date_to: str | None = None,
|
|
113
|
+
page_size: int = 1000,
|
|
114
|
+
) -> list[dict]:
|
|
115
|
+
"""Get all events with automatic pagination."""
|
|
116
|
+
params: dict[str, Any] = {"max": page_size, "first": 0}
|
|
117
|
+
if event_type:
|
|
118
|
+
params["type"] = event_type
|
|
119
|
+
if user:
|
|
120
|
+
params["user"] = user
|
|
121
|
+
if date_from:
|
|
122
|
+
params["dateFrom"] = date_from
|
|
123
|
+
if date_to:
|
|
124
|
+
params["dateTo"] = date_to
|
|
125
|
+
all_events: list[dict] = []
|
|
126
|
+
while True:
|
|
127
|
+
page = self._get("/events", params)
|
|
128
|
+
all_events.extend(page)
|
|
129
|
+
if len(page) < page_size:
|
|
130
|
+
break
|
|
131
|
+
params["first"] += page_size
|
|
132
|
+
return all_events
|
|
133
|
+
|
|
134
|
+
# --- Sessions ---
|
|
135
|
+
|
|
136
|
+
def get_session_stats(self) -> list[dict]:
|
|
137
|
+
"""Get client session statistics."""
|
|
138
|
+
return self._get("/client-session-stats")
|
|
139
|
+
|
|
140
|
+
# --- Clients ---
|
|
141
|
+
|
|
142
|
+
def list_clients(self, max_results: int = 100) -> list[dict]:
|
|
143
|
+
"""List all clients."""
|
|
144
|
+
return self._get("/clients", {"max": max_results})
|
|
145
|
+
|
|
146
|
+
def get_client(self, client_id: str) -> dict:
|
|
147
|
+
"""Get client by internal ID."""
|
|
148
|
+
return self._get(f"/clients/{client_id}")
|
|
149
|
+
|
|
150
|
+
def get_client_by_client_id(self, client_id: str) -> dict | None:
|
|
151
|
+
"""Get client by clientId (not internal UUID)."""
|
|
152
|
+
clients = self._get("/clients", {"clientId": client_id})
|
|
153
|
+
return clients[0] if clients else None
|
|
154
|
+
|
|
155
|
+
def get_client_sessions(self, internal_id: str, max_results: int = 100) -> list[dict]:
|
|
156
|
+
"""Get active sessions for a client."""
|
|
157
|
+
return self._get(f"/clients/{internal_id}/user-sessions", {"max": max_results})
|
|
158
|
+
|
|
159
|
+
# --- Roles ---
|
|
160
|
+
|
|
161
|
+
def get_realm_roles(self) -> list[dict]:
|
|
162
|
+
"""List realm roles."""
|
|
163
|
+
return self._get("/roles")
|