Coverage for app/utils/keycloak_auth.py: 46.10%

154 statements  

« prev     ^ index     » next       coverage.py v7.14.1, created at 2026-07-25 15:51 +0000

1""" 

2Keycloak Authentication & Authorization 

3Handles token validation and user management with Keycloak. 

4""" 

5import logging 

6import threading 

7 

8from fastapi import Depends, HTTPException, status 

9from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer 

10from jose import JWTError, jwt 

11from keycloak import KeycloakAdmin, KeycloakAuthenticationError, KeycloakOpenID 

12from sqlalchemy.orm import Session 

13 

14from app.config import settings 

15from app.database import get_db 

16from app.models import User, UserRole 

17 

18logger = logging.getLogger(__name__) 

19 

20security = HTTPBearer() 

21 

22 

23# ---------------------------------------------------------------- 

24# KEYCLOAK CLIENTS 

25# ---------------------------------------------------------------- 

26def get_keycloak_client() -> KeycloakOpenID: 

27 """Get Keycloak OpenID client instance""" 

28 return KeycloakOpenID( 

29 server_url=settings.KEYCLOAK_SERVER_URL, 

30 client_id=settings.KEYCLOAK_CLIENT_ID, 

31 realm_name=settings.KEYCLOAK_REALM, 

32 client_secret_key=settings.KEYCLOAK_CLIENT_SECRET, 

33 verify=True, 

34 ) 

35 

36 

37def get_keycloak_admin() -> KeycloakAdmin: 

38 """Get Keycloak Admin client (uses service-account client_credentials grant).""" 

39 return KeycloakAdmin( 

40 server_url=settings.KEYCLOAK_SERVER_URL, 

41 realm_name=settings.KEYCLOAK_REALM, 

42 client_id=settings.KEYCLOAK_CLIENT_ID, 

43 client_secret_key=settings.KEYCLOAK_CLIENT_SECRET, 

44 verify=True, 

45 ) 

46 

47 

48# ---------------------------------------------------------------- 

49# PUBLIC KEY CACHE 

50# ---------------------------------------------------------------- 

51# python-keycloak's public_key() does a fresh HTTP GET on every call, 

52# which we make on every authenticated request. The realm signing key 

53# is stable for the process lifetime, so we cache the PEM-formatted key 

54# after the first fetch; restart the process on key rotation. 

55_public_key_pem: str | None = None 

56_public_key_lock = threading.Lock() 

57 

58 

59def _get_realm_public_key_pem() -> str: 

60 global _public_key_pem 

61 if _public_key_pem is not None: 

62 return _public_key_pem 

63 with _public_key_lock: 

64 if _public_key_pem is not None: 

65 return _public_key_pem 

66 raw = get_keycloak_client().public_key() 

67 _public_key_pem = ( 

68 "-----BEGIN PUBLIC KEY-----\n" + raw + "\n-----END PUBLIC KEY-----" 

69 ) 

70 return _public_key_pem 

71 

72 

73# ---------------------------------------------------------------- 

74# TOKEN VALIDATION 

75# ---------------------------------------------------------------- 

76def verify_keycloak_token(token: str) -> dict: 

77 """Validate token via Keycloak introspection (slow, server round-trip).""" 

78 try: 

79 keycloak_client = get_keycloak_client() 

80 token_info = keycloak_client.introspect(token) 

81 if not token_info.get("active"): 

82 raise HTTPException( 

83 status_code=status.HTTP_401_UNAUTHORIZED, 

84 detail="Token is not active or has expired", 

85 headers={"WWW-Authenticate": "Bearer"}, 

86 ) 

87 return token_info 

88 except KeycloakAuthenticationError: 

89 raise HTTPException( 

90 status_code=status.HTTP_401_UNAUTHORIZED, 

91 detail="Authentication failed", 

92 headers={"WWW-Authenticate": "Bearer"}, 

93 ) 

94 except HTTPException: 

95 raise 

96 except Exception: 

97 raise HTTPException( 

98 status_code=status.HTTP_401_UNAUTHORIZED, 

99 detail="Could not validate credentials", 

100 headers={"WWW-Authenticate": "Bearer"}, 

101 ) 

102 

103 

104def verify_keycloak_token_offline(token: str) -> dict: 

105 """Validate JWT signature against Keycloak public key (fast, no server round-trip).""" 

106 try: 

107 public_key = _get_realm_public_key_pem() 

108 return jwt.decode( 

109 token, 

110 public_key, 

111 algorithms=["RS256"], 

112 options={"verify_signature": True, "verify_aud": False, "verify_exp": True}, 

113 ) 

114 except JWTError: 

115 raise HTTPException( 

116 status_code=status.HTTP_401_UNAUTHORIZED, 

117 detail="Invalid token", 

118 headers={"WWW-Authenticate": "Bearer"}, 

119 ) 

120 except Exception: 

121 raise HTTPException( 

122 status_code=status.HTTP_401_UNAUTHORIZED, 

123 detail="Token validation failed", 

124 headers={"WWW-Authenticate": "Bearer"}, 

125 ) 

126 

127 

128# ---------------------------------------------------------------- 

129# ROLE MAPPING 

130# ---------------------------------------------------------------- 

131def map_keycloak_roles_to_app_role(keycloak_roles: list) -> UserRole: 

132 """Priority: admin > teacher > student""" 

133 if "admin" in keycloak_roles: 

134 return UserRole.ADMIN 

135 if "teacher" in keycloak_roles: 

136 return UserRole.TEACHER 

137 return UserRole.STUDENT 

138 

139 

140# ---------------------------------------------------------------- 

141# USER SYNC (Just-in-Time Provisioning) 

142# ---------------------------------------------------------------- 

143def sync_user_from_keycloak(db: Session, keycloak_user_data: dict) -> User: 

144 """ 

145 Synchronize a Keycloak user into the local DB. 

146 Creates the user if missing, updates role/name if changed. 

147 Expects keys: id (or sub), username, email, roles (or realm_access.roles), 

148 firstName/given_name, lastName/family_name. 

149 """ 

150 keycloak_id = keycloak_user_data.get("id") or keycloak_user_data.get("sub") 

151 email = keycloak_user_data.get("email") 

152 username = keycloak_user_data.get("username") or keycloak_id 

153 keycloak_roles = ( 

154 keycloak_user_data.get("roles") 

155 or keycloak_user_data.get("realm_access", {}).get("roles", []) 

156 ) 

157 app_role = map_keycloak_roles_to_app_role(keycloak_roles) 

158 first_name = keycloak_user_data.get("firstName") or keycloak_user_data.get("given_name") 

159 last_name = keycloak_user_data.get("lastName") or keycloak_user_data.get("family_name") 

160 

161 if not email: 

162 raise HTTPException( 

163 status_code=status.HTTP_400_BAD_REQUEST, 

164 detail="Keycloak user has no email; refusing to provision.", 

165 ) 

166 

167 user = db.query(User).filter(User.keycloak_id == keycloak_id).first() 

168 if not user: 

169 user = User( 

170 keycloak_id=keycloak_id, 

171 email=email, 

172 username=username, 

173 role=app_role, 

174 firstName=first_name, 

175 lastName=last_name, 

176 ) 

177 db.add(user) 

178 db.commit() 

179 db.refresh(user) 

180 return user 

181 

182 updated = False 

183 # Email is the identifier used for display, search, and 

184 # notifications; follow the Keycloak record when it changes. 

185 if email and user.email != email: 

186 user.email = email 

187 updated = True 

188 # Username is the login identifier shown in the UI and in 

189 # Terraform-issued credentials; keep it in sync too. 

190 if username and user.username != username: 

191 user.username = username 

192 updated = True 

193 if user.role != app_role: 

194 user.role = app_role 

195 updated = True 

196 if first_name and user.firstName != first_name: 

197 user.firstName = first_name 

198 updated = True 

199 if last_name and user.lastName != last_name: 

200 user.lastName = last_name 

201 updated = True 

202 if updated: 

203 db.commit() 

204 db.refresh(user) 

205 return user 

206 

207 

208# ---------------------------------------------------------------- 

209# AUTH DEPENDENCY 

210# ---------------------------------------------------------------- 

211def get_current_user_keycloak( 

212 credentials: HTTPAuthorizationCredentials = Depends(security), 

213 db: Session = Depends(get_db), 

214) -> User: 

215 """ 

216 Validate the bearer token and return the local User record. 

217 JIT-provisions the user from Keycloak claims on first sight. 

218 """ 

219 token_info = verify_keycloak_token_offline(credentials.credentials) 

220 

221 keycloak_id = token_info.get("sub") 

222 if not keycloak_id: 

223 raise HTTPException( 

224 status_code=status.HTTP_401_UNAUTHORIZED, 

225 detail="Token missing user ID (sub)", 

226 ) 

227 

228 return sync_user_from_keycloak( 

229 db, 

230 { 

231 "id": keycloak_id, 

232 "email": token_info.get("email"), 

233 "username": token_info.get("preferred_username"), 

234 "roles": token_info.get("realm_access", {}).get("roles", []), 

235 "firstName": token_info.get("given_name"), 

236 "lastName": token_info.get("family_name"), 

237 }, 

238 ) 

239 

240 

241# ---------------------------------------------------------------- 

242# KEYCLOAK USER LOOKUP (admin API) 

243# ---------------------------------------------------------------- 

244def _project_keycloak_user(u: dict, *, include_enabled: bool = False) -> dict: 

245 """Project a raw Keycloak user record into our simplified dict shape. 

246 

247 Includes the ``enabled`` flag only when ``include_enabled`` is set, 

248 preserving the differing output shapes of the two call sites. 

249 """ 

250 projected = { 

251 "id": u.get("id"), 

252 "username": u.get("username"), 

253 "email": u.get("email"), 

254 "firstName": u.get("firstName", ""), 

255 "lastName": u.get("lastName", ""), 

256 } 

257 if include_enabled: 

258 projected["enabled"] = u.get("enabled", True) 

259 return projected 

260 

261 

262def search_keycloak_users(search_query: str, max_results: int = 10) -> list[dict]: 

263 """Search Keycloak users by username/email/name (uses service account).""" 

264 try: 

265 keycloak_admin = get_keycloak_admin() 

266 users = keycloak_admin.get_users({"search": search_query, "max": max_results}) 

267 return [ 

268 _project_keycloak_user(u, include_enabled=True) 

269 for u in users 

270 ] 

271 except Exception: 

272 raise HTTPException( 

273 status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, 

274 detail="Failed to search Keycloak users", 

275 ) 

276 

277 

278def get_keycloak_users_by_ids(ids: list[str]) -> dict: 

279 """Resolve a list of Keycloak IDs to simplified user dicts.""" 

280 result: dict = {} 

281 if not ids: 

282 return result 

283 try: 

284 kc = get_keycloak_admin() 

285 for kid in ids: 

286 try: 

287 user = kc.get_user(kid) 

288 except Exception: 

289 try: 

290 users = kc.get_users({"search": kid, "max": 1}) 

291 user = users[0] if users else None 

292 except Exception: 

293 user = None 

294 if not user: 

295 continue 

296 projected = _project_keycloak_user(user) 

297 projected["id"] = user.get("id") or kid 

298 result[user.get("id") or kid] = projected 

299 return result 

300 except Exception: 

301 raise HTTPException( 

302 status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, 

303 detail="Failed to fetch Keycloak users", 

304 ) 

305 

306 

307# ---------------------------------------------------------------- 

308# JIT REFRESH FOR OUTGOING SIDE-CHANNELS (e.g. mail) 

309# ---------------------------------------------------------------- 

310def refresh_user_from_keycloak(db: Session, user: User) -> User: 

311 """Pull the latest Keycloak record for ``user`` and reconcile our DB row. 

312 

313 Used by paths that address a user outside of an active HTTP request 

314 (most importantly the mail notifier) so credentials go to the 

315 current email even if it changed since the wizard pick. 

316 

317 Best-effort by design: 

318 

319 * If the user has no ``keycloak_id``, there is nothing to refresh — 

320 return the row unchanged. 

321 * If the Admin API is down or the user was deleted in Keycloak, log 

322 at WARNING and return the row unchanged so a flaky KC doesn't 

323 block notifications. 

324 

325 Reuses :func:`sync_user_from_keycloak` for the DB write. 

326 """ 

327 if not user or not getattr(user, "keycloak_id", None): 

328 return user 

329 

330 try: 

331 kc = get_keycloak_admin() 

332 kc_user = kc.get_user(user.keycloak_id) 

333 except Exception as e: 

334 logger.warning( 

335 "refresh_user_from_keycloak: get_user(%s) failed (%s); " 

336 "falling back to DB record for %s", 

337 user.keycloak_id, e, user.email, 

338 ) 

339 return user 

340 

341 if not kc_user: 

342 logger.warning( 

343 "refresh_user_from_keycloak: keycloak returned no user for id=%s; " 

344 "user may have been deleted upstream — keeping DB record for %s", 

345 user.keycloak_id, user.email, 

346 ) 

347 return user 

348 

349 # ``get_user`` doesn't include realm roles; pre-seed with the 

350 # current role mapping so re-running role mapping doesn't cause a 

351 # spurious downgrade. Role rotation goes through the auth dependency 

352 # on next login. 

353 current_role_token = [] 

354 if user.role == UserRole.ADMIN: 

355 current_role_token = ["admin"] 

356 elif user.role == UserRole.TEACHER: 

357 current_role_token = ["teacher"] 

358 

359 try: 

360 return sync_user_from_keycloak( 

361 db, 

362 { 

363 "id": kc_user.get("id") or user.keycloak_id, 

364 "email": kc_user.get("email"), 

365 "username": kc_user.get("username"), 

366 "firstName": kc_user.get("firstName"), 

367 "lastName": kc_user.get("lastName"), 

368 "roles": current_role_token, 

369 }, 

370 ) 

371 except HTTPException as e: 

372 # ``sync_user_from_keycloak`` raises 400 when KC returns a user 

373 # without an email; defend in depth so a bad record doesn't 

374 # block the notifier. 

375 logger.warning( 

376 "refresh_user_from_keycloak: sync rejected KC payload for %s: %s", 

377 user.keycloak_id, e.detail, 

378 ) 

379 return user