diff --git a/.env b/.env
index 2e0ece7..ecef87d 100644
--- a/.env
+++ b/.env
@@ -8,12 +8,12 @@ DB_NAME=member_center
DB_USER=member_ose
DB_PASSWORD=CHANGE_ME
-AUTHENTIK_BASE_URL=
-AUTHENTIK_ADMIN_TOKEN=
-AUTHENTIK_VERIFY_TLS=false
-AUTHENTIK_ISSUER=
-AUTHENTIK_JWKS_URL=
-AUTHENTIK_AUDIENCE=
+KEYCLOAK_BASE_URL=
+KEYCLOAK_REALM=
+KEYCLOAK_VERIFY_TLS=true
+KEYCLOAK_ISSUER=
+KEYCLOAK_JWKS_URL=
+KEYCLOAK_AUDIENCE=
PUBLIC_FRONTEND_ORIGINS=https://member.ose.tw,https://mkt.ose.tw,https://admin.ose.tw
INTERNAL_SHARED_SECRET=CHANGE_ME
diff --git a/.env.development b/.env.development
index e8d7e1b..dd42105 100644
--- a/.env.development
+++ b/.env.development
@@ -8,17 +8,6 @@ DB_NAME=member.ose.tw
DB_USER=member_ose
DB_PASSWORD=Dmrax5bKDf
-AUTHENTIK_BASE_URL=https://auth.ose.tw
-AUTHENTIK_ADMIN_TOKEN=YAujCdjuEGhONGZnrkF0pSKf6t1CIqNCwnzTVFGQFkkfxGqFG8HKQUvKmm4S
-AUTHENTIK_VERIFY_TLS=true
-AUTHENTIK_ISSUER=https://auth.ose.tw/application/o/member-ose-frontend/
-AUTHENTIK_JWKS_URL=https://auth.ose.tw/application/o/member-ose-frontend/jwks/
-AUTHENTIK_AUDIENCE=gKtjk5ExsITK74I1WG9RkHbylBjoZO83xab7YHiN
-AUTHENTIK_CLIENT_ID=gKtjk5ExsITK74I1WG9RkHbylBjoZO83xab7YHiN
-AUTHENTIK_CLIENT_SECRET=MHTv0SHkIuic9Quk8Br9jB9gzT2bERvRfhHU4ogPlUtY3eBEXJj80RTEp3zpFBUXQ8PAwYrihWfNqKawWUOmKpQd8SwuyiAuVwLJTS7vB3LGvx1XtXqgMhR76EL2mLnP
-AUTHENTIK_TOKEN_ENDPOINT=https://auth.ose.tw/application/o/token/
-AUTHENTIK_USERINFO_ENDPOINT=https://auth.ose.tw/application/o/userinfo/
-
KEYCLOAK_BASE_URL=https://auth.ose.tw
KEYCLOAK_REALM=master
KEYCLOAK_VERIFY_TLS=true
diff --git a/.env.example b/.env.example
index fc76f89..03c3592 100644
--- a/.env.example
+++ b/.env.example
@@ -8,18 +8,6 @@ DB_NAME=member_center
DB_USER=member_ose
DB_PASSWORD=CHANGE_ME
-AUTHENTIK_BASE_URL=
-AUTHENTIK_ADMIN_TOKEN=
-AUTHENTIK_VERIFY_TLS=false
-AUTHENTIK_ISSUER=
-AUTHENTIK_JWKS_URL=
-AUTHENTIK_AUDIENCE=
-AUTHENTIK_CLIENT_ID=
-AUTHENTIK_CLIENT_SECRET=
-AUTHENTIK_TOKEN_ENDPOINT=
-AUTHENTIK_USERINFO_ENDPOINT=
-
-
# Keycloak (preferred when KEYCLOAK_BASE_URL + KEYCLOAK_REALM are set)
KEYCLOAK_BASE_URL=
KEYCLOAK_REALM=
diff --git a/.env.production.example b/.env.production.example
index 328bf59..fc4d7a0 100644
--- a/.env.production.example
+++ b/.env.production.example
@@ -8,17 +8,6 @@ DB_NAME=member_center
DB_USER=member_ose
DB_PASSWORD=CHANGE_ME
-AUTHENTIK_BASE_URL=
-AUTHENTIK_ADMIN_TOKEN=
-AUTHENTIK_VERIFY_TLS=false
-AUTHENTIK_ISSUER=
-AUTHENTIK_JWKS_URL=
-AUTHENTIK_AUDIENCE=
-AUTHENTIK_CLIENT_ID=
-AUTHENTIK_CLIENT_SECRET=
-AUTHENTIK_TOKEN_ENDPOINT=
-AUTHENTIK_USERINFO_ENDPOINT=
-
# Keycloak (preferred when KEYCLOAK_BASE_URL + KEYCLOAK_REALM are set)
KEYCLOAK_BASE_URL=
KEYCLOAK_REALM=
diff --git a/README.md b/README.md
index 23cfbc4..62ae29c 100644
--- a/README.md
+++ b/README.md
@@ -11,60 +11,23 @@ cp .env.example .env
./scripts/start_dev.sh
```
-## Required DB setup
+## Keycloak env
-1. Initialize API client whitelist table with `docs/API_CLIENTS_SQL.sql`.
-2. Initialize core tables with `backend/scripts/init_schema.sql`.
-3. Generate `api_key_hash` and update `api_clients` records, e.g.:
-
-```bash
-python scripts/generate_api_key_hash.py 'YOUR_PLAIN_KEY'
-```
-
-## IdP JWT setup(Keycloak 優先)
-
-- 若設定 `KEYCLOAK_BASE_URL` + `KEYCLOAK_REALM`,後端會優先走 Keycloak。
-- 未設定 Keycloak 時,才走 `AUTHENTIK_*`。
-
-### Keycloak
-- 必填:
+- Required:
- `KEYCLOAK_BASE_URL`
- `KEYCLOAK_REALM`
- `KEYCLOAK_CLIENT_ID`
- `KEYCLOAK_CLIENT_SECRET`
-- 可選:
- - `KEYCLOAK_ISSUER`(預設:`/realms/`)
- - `KEYCLOAK_JWKS_URL`(預設:`/protocol/openid-connect/certs`)
- - `KEYCLOAK_TOKEN_ENDPOINT`(預設:`/protocol/openid-connect/token`)
- - `KEYCLOAK_USERINFO_ENDPOINT`(預設:`/protocol/openid-connect/userinfo`)
- - `KEYCLOAK_AUDIENCE`
- - `KEYCLOAK_VERIFY_TLS`(預設 true)
-
-### Authentik(備援)
-
-- Configure at least one of:
- - `AUTHENTIK_JWKS_URL`
- - `AUTHENTIK_ISSUER` (the service infers `/jwks/`)
-- Optional:
- - `AUTHENTIK_AUDIENCE` (enables audience claim validation)
- - `AUTHENTIK_CLIENT_ID` (used by `/auth/login`, fallback to `AUTHENTIK_AUDIENCE`)
- - `AUTHENTIK_CLIENT_SECRET` (required if your access/id token uses HS256 signing)
- - `AUTHENTIK_TOKEN_ENDPOINT` (default: `/application/o/token/`)
- - `AUTHENTIK_USERINFO_ENDPOINT` (optional, default inferred from issuer/base URL; used to fill missing email/name claims)
-
-## IdP Admin API setup
-
-- Keycloak(優先)
- - `KEYCLOAK_BASE_URL`
- - `KEYCLOAK_REALM`
- `KEYCLOAK_ADMIN_CLIENT_ID`
- `KEYCLOAK_ADMIN_CLIENT_SECRET`
- - `KEYCLOAK_ADMIN_REALM`(可選,預設同 `KEYCLOAK_REALM`)
-
-- Authentik(備援)
- - `AUTHENTIK_BASE_URL`
- - `AUTHENTIK_ADMIN_TOKEN`
- - `AUTHENTIK_VERIFY_TLS`
+- Optional:
+ - `KEYCLOAK_ADMIN_REALM` (default = `KEYCLOAK_REALM`)
+ - `KEYCLOAK_ISSUER`
+ - `KEYCLOAK_JWKS_URL`
+ - `KEYCLOAK_TOKEN_ENDPOINT`
+ - `KEYCLOAK_USERINFO_ENDPOINT`
+ - `KEYCLOAK_AUDIENCE`
+ - `KEYCLOAK_VERIFY_TLS`
## Main APIs
@@ -75,19 +38,4 @@ python scripts/generate_api_key_hash.py 'YOUR_PLAIN_KEY'
- `GET /me/permissions/snapshot` (Bearer token required)
- `POST /internal/users/upsert-by-sub`
- `GET /internal/permissions/{user_sub}/snapshot`
-- `POST /internal/idp/users/ensure`(相容:`/internal/authentik/users/ensure`)
-- `POST /admin/permissions/grant`
-- `POST /admin/permissions/revoke`
-- `GET|POST /admin/systems`
-- `GET|POST /admin/modules`
-- `GET|POST /admin/companies`
-- `GET|POST /admin/sites`
-- `GET /admin/members`
-- `GET|POST /admin/permission-groups`
-- `POST|DELETE /admin/permission-groups/{group_key}/members/{user_sub}`
-- `POST /admin/permission-groups/{group_key}/permissions/grant|revoke`
-- `GET /internal/systems`
-- `GET /internal/modules`
-- `GET /internal/companies`
-- `GET /internal/sites`
-- `GET /internal/members`
+- `POST /internal/idp/users/ensure`
diff --git a/app/api/admin_catalog.py b/app/api/admin_catalog.py
index f5e6933..c56ab03 100644
--- a/app/api/admin_catalog.py
+++ b/app/api/admin_catalog.py
@@ -53,7 +53,7 @@ from app.schemas.api_clients import (
from app.schemas.permissions import PermissionGrantRequest, PermissionRevokeRequest
from app.security.admin_guard import require_admin_principal
from app.security.api_client_auth import hash_api_key
-from app.services.authentik_admin_service import AuthentikAdminService
+from app.services.idp_admin_service import KeycloakAdminService
router = APIRouter(
prefix="/admin",
@@ -133,7 +133,7 @@ def _generate_api_key() -> str:
return secrets.token_urlsafe(36)
-def _sync_member_to_authentik(
+def _sync_member_to_idp(
*,
user_sub: str | None,
idp_user_id: str | None,
@@ -143,9 +143,9 @@ def _sync_member_to_authentik(
is_active: bool,
) -> dict[str, str]:
if not email:
- raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="email_required_for_authentik_sync")
+ raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="email_required_for_idp_sync")
settings = get_settings()
- service = AuthentikAdminService(settings=settings)
+ service = KeycloakAdminService(settings=settings)
result = service.ensure_user(
sub=user_sub,
email=email,
@@ -590,11 +590,11 @@ def upsert_member(
resolved_sub = payload.user_sub
resolved_username = payload.username
idp_user_id = None
- if payload.sync_to_authentik:
+ if payload.sync_to_idp:
seed_sub = payload.user_sub or payload.username
if not seed_sub:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="user_sub_or_username_required")
- sync = _sync_member_to_authentik(
+ sync = _sync_member_to_idp(
user_sub=seed_sub,
idp_user_id=idp_user_id,
username=payload.username,
@@ -642,8 +642,8 @@ def update_member(
next_is_active = payload.is_active if payload.is_active is not None else row.is_active
idp_user_id = row.idp_user_id
- if payload.sync_to_authentik:
- sync = _sync_member_to_authentik(
+ if payload.sync_to_idp:
+ sync = _sync_member_to_idp(
user_sub=row.user_sub,
idp_user_id=row.idp_user_id,
username=next_username,
@@ -681,7 +681,7 @@ def delete_member(
if not row:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="user_not_found")
settings = get_settings()
- service = AuthentikAdminService(settings=settings)
+ service = KeycloakAdminService(settings=settings)
service.delete_user(
idp_user_id=row.idp_user_id,
email=row.email,
@@ -703,7 +703,7 @@ def reset_member_password(
if not user:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="user_not_found")
settings = get_settings()
- service = AuthentikAdminService(settings=settings)
+ service = KeycloakAdminService(settings=settings)
result = service.reset_password(
idp_user_id=user.idp_user_id,
email=user.email,
diff --git a/app/api/auth.py b/app/api/auth.py
index 7d15ca1..3dd102e 100644
--- a/app/api/auth.py
+++ b/app/api/auth.py
@@ -5,60 +5,18 @@ import httpx
from fastapi import APIRouter, HTTPException, status
from app.core.config import get_settings
-from app.schemas.login import (
- LoginRequest,
- LoginResponse,
- OIDCAuthUrlResponse,
- OIDCCodeExchangeRequest,
-)
+from app.schemas.login import LoginRequest, LoginResponse, OIDCAuthUrlResponse, OIDCCodeExchangeRequest
router = APIRouter(prefix="/auth", tags=["auth"])
logger = logging.getLogger(__name__)
-def _resolve_username_by_email(settings, email: str) -> str | None:
- # Authentik-only helper. Keycloak does not need this path.
- if settings.use_keycloak:
- return None
- if not settings.authentik_base_url or not settings.authentik_admin_token:
- return None
-
- url = urljoin(settings.authentik_base_url.rstrip("/") + "/", "api/v3/core/users/")
- try:
- resp = httpx.get(
- url,
- params={"email": email},
- timeout=10,
- verify=settings.authentik_verify_tls,
- headers={
- "Authorization": f"Bearer {settings.authentik_admin_token}",
- "Accept": "application/json",
- },
- )
- except Exception:
- return None
-
- if resp.status_code >= 400:
- return None
-
- data = resp.json()
- results = data.get("results") if isinstance(data, dict) else None
- if not isinstance(results, list) or not results:
- return None
-
- username = results[0].get("username")
- return username if isinstance(username, str) and username else None
-
-
@router.post("/login", response_model=LoginResponse)
def login(payload: LoginRequest) -> LoginResponse:
settings = get_settings()
client_id = settings.idp_client_id or settings.idp_audience
-
if not settings.idp_base_url or not client_id or not settings.idp_client_secret:
- raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="authentik_login_not_configured")
-
- token_endpoint = settings.idp_token_endpoint
+ raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="idp_login_not_configured")
form = {
"grant_type": "password",
@@ -68,31 +26,16 @@ def login(payload: LoginRequest) -> LoginResponse:
"password": payload.password,
"scope": "openid profile email",
}
-
- def _token_request(form_data: dict[str, str]) -> httpx.Response:
+ try:
resp = httpx.post(
- token_endpoint,
- data=form_data,
+ settings.idp_token_endpoint,
+ data=form,
timeout=10,
verify=settings.idp_verify_tls,
headers={"Content-Type": "application/x-www-form-urlencoded"},
)
- return resp
-
- try:
- resp = _token_request(form)
except Exception as exc:
- raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail="authentik_unreachable") from exc
-
- # If user entered email, try resolving username and retry once.
- if resp.status_code >= 400 and "@" in payload.username:
- resolved = _resolve_username_by_email(settings, payload.username)
- if resolved and resolved != payload.username:
- form["username"] = resolved
- try:
- resp = _token_request(form)
- except Exception as exc:
- raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail="authentik_unreachable") from exc
+ raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail="idp_unreachable") from exc
if resp.status_code >= 400:
logger.warning("idp password grant failed: status=%s body=%s", resp.status_code, resp.text)
@@ -101,8 +44,7 @@ def login(payload: LoginRequest) -> LoginResponse:
data = resp.json()
token = data.get("access_token")
if not token:
- raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail="authentik_missing_access_token")
-
+ raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail="idp_missing_access_token")
return LoginResponse(
access_token=token,
token_type=data.get("token_type", "Bearer"),
@@ -123,28 +65,26 @@ def get_oidc_authorize_url(
settings = get_settings()
client_id = settings.idp_client_id or settings.idp_audience
if not settings.idp_base_url or not client_id:
- raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="authentik_login_not_configured")
+ raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="idp_login_not_configured")
- authorize_endpoint = settings.idp_authorize_endpoint
- state = secrets.token_urlsafe(24)
query = {
"client_id": client_id,
"response_type": "code",
"scope": "openid profile email",
"redirect_uri": redirect_uri,
- "state": state,
+ "state": secrets.token_urlsafe(24),
"prompt": prompt or "login",
}
if login_hint:
query["login_hint"] = login_hint
- if idp_hint and settings.use_keycloak:
+ if idp_hint:
query["kc_idp_hint"] = idp_hint
if code_challenge:
query["code_challenge"] = code_challenge
query["code_challenge_method"] = code_challenge_method or "S256"
params = httpx.QueryParams(query)
- return OIDCAuthUrlResponse(authorize_url=f"{authorize_endpoint}?{params}")
+ return OIDCAuthUrlResponse(authorize_url=f"{settings.idp_authorize_endpoint}?{params}")
@router.post("/oidc/exchange", response_model=LoginResponse)
@@ -152,9 +92,8 @@ def exchange_oidc_code(payload: OIDCCodeExchangeRequest) -> LoginResponse:
settings = get_settings()
client_id = settings.idp_client_id or settings.idp_audience
if not settings.idp_base_url or not client_id or not settings.idp_client_secret:
- raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="authentik_login_not_configured")
+ raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="idp_login_not_configured")
- token_endpoint = settings.idp_token_endpoint
form = {
"grant_type": "authorization_code",
"client_id": client_id,
@@ -167,24 +106,23 @@ def exchange_oidc_code(payload: OIDCCodeExchangeRequest) -> LoginResponse:
try:
resp = httpx.post(
- token_endpoint,
+ settings.idp_token_endpoint,
data=form,
timeout=10,
verify=settings.idp_verify_tls,
headers={"Content-Type": "application/x-www-form-urlencoded"},
)
except Exception as exc:
- raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail="authentik_unreachable") from exc
+ raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail="idp_unreachable") from exc
if resp.status_code >= 400:
logger.warning("idp auth-code exchange failed: status=%s body=%s", resp.status_code, resp.text)
- raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="authentik_code_exchange_failed")
+ raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="idp_code_exchange_failed")
data = resp.json()
token = data.get("access_token")
if not token:
- raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail="authentik_missing_access_token")
-
+ raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail="idp_missing_access_token")
return LoginResponse(
access_token=token,
token_type=data.get("token_type", "Bearer"),
diff --git a/app/api/internal.py b/app/api/internal.py
index 86d34d5..1e2dbca 100644
--- a/app/api/internal.py
+++ b/app/api/internal.py
@@ -6,11 +6,11 @@ from app.db.session import get_db
from app.repositories.permissions_repo import PermissionsRepository
from app.schemas.internal import InternalUpsertUserBySubResponse
from app.repositories.users_repo import UsersRepository
-from app.schemas.authentik_admin import AuthentikEnsureUserRequest, AuthentikEnsureUserResponse
+from app.schemas.idp_admin import KeycloakEnsureUserRequest, KeycloakEnsureUserResponse
from app.schemas.permissions import PermissionSnapshotResponse
from app.schemas.users import UserUpsertBySubRequest
from app.security.api_client_auth import require_api_client
-from app.services.authentik_admin_service import AuthentikAdminService
+from app.services.idp_admin_service import KeycloakAdminService
from app.services.permission_service import PermissionService
router = APIRouter(prefix="/internal", tags=["internal"], dependencies=[Depends(require_api_client)])
@@ -56,16 +56,15 @@ def get_permission_snapshot(
return PermissionService.build_snapshot(user_sub=user_sub, permissions=permissions)
-@router.post("/authentik/users/ensure", response_model=AuthentikEnsureUserResponse)
-@router.post("/idp/users/ensure", response_model=AuthentikEnsureUserResponse)
-@router.post("/keycloak/users/ensure", response_model=AuthentikEnsureUserResponse)
-def ensure_authentik_user(
- payload: AuthentikEnsureUserRequest,
+@router.post("/idp/users/ensure", response_model=KeycloakEnsureUserResponse)
+@router.post("/keycloak/users/ensure", response_model=KeycloakEnsureUserResponse)
+def ensure_idp_user(
+ payload: KeycloakEnsureUserRequest,
db: Session = Depends(get_db),
-) -> AuthentikEnsureUserResponse:
+) -> KeycloakEnsureUserResponse:
settings = get_settings()
- authentik_service = AuthentikAdminService(settings=settings)
- sync_result = authentik_service.ensure_user(
+ idp_service = KeycloakAdminService(settings=settings)
+ sync_result = idp_service.ensure_user(
sub=payload.user_sub,
email=payload.email,
username=payload.username,
@@ -78,7 +77,7 @@ def ensure_authentik_user(
if sync_result.user_sub:
resolved_sub = sync_result.user_sub
if not resolved_sub:
- raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail="authentik_missing_sub")
+ raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail="idp_missing_sub")
users_repo.upsert_by_sub(
user_sub=resolved_sub,
username=payload.username,
@@ -87,4 +86,4 @@ def ensure_authentik_user(
is_active=payload.is_active,
idp_user_id=sync_result.user_id,
)
- return AuthentikEnsureUserResponse(idp_user_id=sync_result.user_id, action=sync_result.action)
+ return KeycloakEnsureUserResponse(idp_user_id=sync_result.user_id, action=sync_result.action)
diff --git a/app/api/me.py b/app/api/me.py
index e9e1c2b..5a0937e 100644
--- a/app/api/me.py
+++ b/app/api/me.py
@@ -5,9 +5,9 @@ from sqlalchemy.orm import Session
from app.db.session import get_db
from app.repositories.permissions_repo import PermissionsRepository
from app.repositories.users_repo import UsersRepository
-from app.schemas.auth import AuthentikPrincipal, MeSummaryResponse
+from app.schemas.auth import KeycloakPrincipal, MeSummaryResponse
from app.schemas.permissions import PermissionSnapshotResponse
-from app.security.authentik_jwt import require_authenticated_principal
+from app.security.idp_jwt import require_authenticated_principal
from app.services.permission_service import PermissionService
router = APIRouter(prefix="/me", tags=["me"])
@@ -15,7 +15,7 @@ router = APIRouter(prefix="/me", tags=["me"])
@router.get("", response_model=MeSummaryResponse)
def get_me(
- principal: AuthentikPrincipal = Depends(require_authenticated_principal),
+ principal: KeycloakPrincipal = Depends(require_authenticated_principal),
db: Session = Depends(get_db),
) -> MeSummaryResponse:
try:
@@ -39,7 +39,7 @@ def get_me(
@router.get("/permissions/snapshot", response_model=PermissionSnapshotResponse)
def get_my_permission_snapshot(
- principal: AuthentikPrincipal = Depends(require_authenticated_principal),
+ principal: KeycloakPrincipal = Depends(require_authenticated_principal),
db: Session = Depends(get_db),
) -> PermissionSnapshotResponse:
try:
diff --git a/app/core/config.py b/app/core/config.py
index 6e0a190..e055a6a 100644
--- a/app/core/config.py
+++ b/app/core/config.py
@@ -17,17 +17,7 @@ class Settings(BaseSettings):
db_user: str = "member_ose"
db_password: str = ""
- authentik_base_url: str = ""
- authentik_admin_token: str = ""
- authentik_verify_tls: bool = False
- authentik_issuer: str = ""
- authentik_jwks_url: str = ""
- authentik_audience: str = ""
- authentik_client_id: str = ""
- authentik_client_secret: str = ""
- authentik_token_endpoint: str = ""
- authentik_userinfo_endpoint: str = ""
- # Keycloak (preferred when configured)
+ # Keycloak only
keycloak_base_url: str = ""
keycloak_realm: str = ""
keycloak_verify_tls: bool = True
@@ -71,79 +61,53 @@ class Settings(BaseSettings):
f"{self.db_user}:{self.db_password}@{self.db_host}:{self.db_port}/{self.db_name}"
)
- @property
- def use_keycloak(self) -> bool:
- return bool(self.keycloak_base_url and self.keycloak_realm)
-
@property
def idp_base_url(self) -> str:
- if self.use_keycloak:
- return self.keycloak_base_url.rstrip("/")
- return self.authentik_base_url.rstrip("/")
+ return self.keycloak_base_url.rstrip("/")
@property
def idp_verify_tls(self) -> bool:
- if self.use_keycloak:
- return self.keycloak_verify_tls
- return self.authentik_verify_tls
+ return self.keycloak_verify_tls
@property
def idp_issuer(self) -> str:
- if self.use_keycloak:
- if self.keycloak_issuer:
- return self.keycloak_issuer.rstrip("/")
- return f"{self.idp_base_url}/realms/{self.keycloak_realm}"
- return self.authentik_issuer.rstrip("/")
+ if self.keycloak_issuer:
+ return self.keycloak_issuer.rstrip("/")
+ return f"{self.idp_base_url}/realms/{self.keycloak_realm}"
@property
def idp_jwks_url(self) -> str:
- if self.use_keycloak:
- if self.keycloak_jwks_url:
- return self.keycloak_jwks_url
- return f"{self.idp_issuer}/protocol/openid-connect/certs"
- return self.authentik_jwks_url
+ if self.keycloak_jwks_url:
+ return self.keycloak_jwks_url
+ return f"{self.idp_issuer}/protocol/openid-connect/certs"
@property
def idp_audience(self) -> str:
- if self.use_keycloak:
- return self.keycloak_audience
- return self.authentik_audience or self.authentik_client_id
+ return self.keycloak_audience
@property
def idp_client_id(self) -> str:
- if self.use_keycloak:
- return self.keycloak_client_id
- return self.authentik_client_id
+ return self.keycloak_client_id
@property
def idp_client_secret(self) -> str:
- if self.use_keycloak:
- return self.keycloak_client_secret
- return self.authentik_client_secret
+ return self.keycloak_client_secret
@property
def idp_token_endpoint(self) -> str:
- if self.use_keycloak:
- if self.keycloak_token_endpoint:
- return self.keycloak_token_endpoint
- return f"{self.idp_issuer}/protocol/openid-connect/token"
- return self.authentik_token_endpoint or (f"{self.idp_base_url}/application/o/token/" if self.idp_base_url else "")
+ if self.keycloak_token_endpoint:
+ return self.keycloak_token_endpoint
+ return f"{self.idp_issuer}/protocol/openid-connect/token"
@property
def idp_userinfo_endpoint(self) -> str:
- if self.use_keycloak:
- if self.keycloak_userinfo_endpoint:
- return self.keycloak_userinfo_endpoint
- return f"{self.idp_issuer}/protocol/openid-connect/userinfo"
- return self.authentik_userinfo_endpoint or (
- f"{self.idp_base_url}/application/o/userinfo/" if self.idp_base_url else ""
- )
+ if self.keycloak_userinfo_endpoint:
+ return self.keycloak_userinfo_endpoint
+ return f"{self.idp_issuer}/protocol/openid-connect/userinfo"
@property
def idp_authorize_endpoint(self) -> str:
- if self.use_keycloak:
- return f"{self.idp_issuer}/protocol/openid-connect/auth"
- return f"{self.idp_base_url}/application/o/authorize/" if self.idp_base_url else ""
+ return f"{self.idp_issuer}/protocol/openid-connect/auth"
@lru_cache
diff --git a/app/schemas/auth.py b/app/schemas/auth.py
index b89dbdd..ea9e100 100644
--- a/app/schemas/auth.py
+++ b/app/schemas/auth.py
@@ -1,7 +1,7 @@
from pydantic import BaseModel, Field
-class AuthentikPrincipal(BaseModel):
+class KeycloakPrincipal(BaseModel):
sub: str
email: str | None = None
name: str | None = None
diff --git a/app/schemas/catalog.py b/app/schemas/catalog.py
index 228a6ef..92a4f59 100644
--- a/app/schemas/catalog.py
+++ b/app/schemas/catalog.py
@@ -90,7 +90,7 @@ class MemberUpsertRequest(BaseModel):
email: str | None = None
display_name: str | None = None
is_active: bool = True
- sync_to_authentik: bool = True
+ sync_to_idp: bool = True
class MemberUpdateRequest(BaseModel):
@@ -98,7 +98,7 @@ class MemberUpdateRequest(BaseModel):
email: str | None = None
display_name: str | None = None
is_active: bool | None = None
- sync_to_authentik: bool = True
+ sync_to_idp: bool = True
class MemberPasswordResetResponse(BaseModel):
diff --git a/app/schemas/authentik_admin.py b/app/schemas/idp_admin.py
similarity index 76%
rename from app/schemas/authentik_admin.py
rename to app/schemas/idp_admin.py
index 374e74a..9f094ed 100644
--- a/app/schemas/authentik_admin.py
+++ b/app/schemas/idp_admin.py
@@ -1,7 +1,7 @@
from pydantic import AliasChoices, BaseModel, Field
-class AuthentikEnsureUserRequest(BaseModel):
+class KeycloakEnsureUserRequest(BaseModel):
user_sub: str | None = Field(default=None, validation_alias=AliasChoices("user_sub", "sub"))
username: str | None = None
email: str
@@ -9,6 +9,6 @@ class AuthentikEnsureUserRequest(BaseModel):
is_active: bool = True
-class AuthentikEnsureUserResponse(BaseModel):
+class KeycloakEnsureUserResponse(BaseModel):
idp_user_id: str
action: str
diff --git a/app/security/admin_guard.py b/app/security/admin_guard.py
index f669858..22e2bea 100644
--- a/app/security/admin_guard.py
+++ b/app/security/admin_guard.py
@@ -1,13 +1,13 @@
from fastapi import Depends, HTTPException, status
from app.core.config import get_settings
-from app.schemas.auth import AuthentikPrincipal
-from app.security.authentik_jwt import require_authenticated_principal
+from app.schemas.auth import KeycloakPrincipal
+from app.security.idp_jwt import require_authenticated_principal
def require_admin_principal(
- principal: AuthentikPrincipal = Depends(require_authenticated_principal),
-) -> AuthentikPrincipal:
+ principal: KeycloakPrincipal = Depends(require_authenticated_principal),
+) -> KeycloakPrincipal:
settings = get_settings()
required_groups = {group.lower() for group in settings.admin_required_groups}
diff --git a/app/security/authentik_jwt.py b/app/security/idp_jwt.py
similarity index 68%
rename from app/security/authentik_jwt.py
rename to app/security/idp_jwt.py
index 2890a78..d6bf1c5 100644
--- a/app/security/authentik_jwt.py
+++ b/app/security/idp_jwt.py
@@ -1,6 +1,7 @@
from __future__ import annotations
from functools import lru_cache
+import logging
import httpx
import jwt
@@ -8,17 +9,19 @@ from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from app.core.config import get_settings
-from app.schemas.auth import AuthentikPrincipal
+from app.schemas.auth import KeycloakPrincipal
bearer_scheme = HTTPBearer(auto_error=False)
+logger = logging.getLogger(__name__)
-class AuthentikTokenVerifier:
+class KeycloakTokenVerifier:
def __init__(
self,
issuer: str | None,
jwks_url: str | None,
audience: str | None,
+ client_id: str | None,
client_secret: str | None,
base_url: str | None,
userinfo_endpoint: str | None,
@@ -27,6 +30,7 @@ class AuthentikTokenVerifier:
self.issuer = issuer.strip() if issuer else None
self.jwks_url = jwks_url.strip() if jwks_url else self._infer_jwks_url(self.issuer)
self.audience = audience.strip() if audience else None
+ self.client_id = client_id.strip() if client_id else None
self.client_secret = client_secret.strip() if client_secret else None
self.base_url = base_url.strip() if base_url else None
self.userinfo_endpoint = (
@@ -35,36 +39,59 @@ class AuthentikTokenVerifier:
self.verify_tls = verify_tls
if not self.jwks_url:
- raise ValueError("AUTHENTIK_JWKS_URL or AUTHENTIK_ISSUER is required")
+ raise ValueError("KEYCLOAK_JWKS_URL or KEYCLOAK_ISSUER is required")
self._jwk_client = jwt.PyJWKClient(self.jwks_url)
+ @staticmethod
+ def _infer_introspection_endpoint(issuer: str | None) -> str | None:
+ if not issuer:
+ return None
+ normalized = issuer.rstrip("/")
+ if "/realms/" in normalized:
+ return normalized + "/protocol/openid-connect/token/introspect"
+ return None
+
+ def _introspect_token(self, token: str) -> dict | None:
+ endpoint = self._infer_introspection_endpoint(self.issuer)
+ if not endpoint or not self.client_id or not self.client_secret:
+ return None
+ try:
+ resp = httpx.post(
+ endpoint,
+ timeout=8,
+ verify=self.verify_tls,
+ headers={"Content-Type": "application/x-www-form-urlencoded"},
+ data={
+ "token": token,
+ "client_id": self.client_id,
+ "client_secret": self.client_secret,
+ },
+ )
+ except Exception:
+ return None
+ if resp.status_code >= 400:
+ return None
+ data = resp.json() if resp.content else {}
+ if not isinstance(data, dict) or not data.get("active"):
+ return None
+ return data
+
@staticmethod
def _infer_jwks_url(issuer: str | None) -> str | None:
if not issuer:
return None
- normalized = issuer.rstrip("/") + "/"
- if normalized.endswith("/jwks/"):
- return normalized
- return normalized + "jwks/"
+ return issuer.rstrip("/") + "/protocol/openid-connect/certs"
@staticmethod
def _infer_userinfo_endpoint(issuer: str | None, base_url: str | None) -> str | None:
if issuer:
- normalized = issuer.rstrip("/")
- if "/realms/" in normalized:
- return normalized + "/protocol/openid-connect/userinfo"
- marker = "/application/o/"
- marker_index = normalized.find(marker)
- if marker_index != -1:
- root = normalized[:marker_index]
- return root + marker + "userinfo/"
- return normalized + "/userinfo/"
+ return issuer.rstrip("/") + "/protocol/openid-connect/userinfo"
if base_url:
- return base_url.rstrip("/") + "/application/o/userinfo/"
+ return base_url.rstrip("/") + "/realms/master/protocol/openid-connect/userinfo"
return None
- def _enrich_from_userinfo(self, principal: AuthentikPrincipal, token: str) -> AuthentikPrincipal:
+ def _enrich_from_userinfo(self, principal: KeycloakPrincipal, token: str) -> KeycloakPrincipal:
if principal.email and (principal.name or principal.preferred_username) and principal.groups:
return principal
if not self.userinfo_endpoint:
@@ -97,7 +124,7 @@ class AuthentikTokenVerifier:
payload_groups = data.get("groups")
if isinstance(payload_groups, list):
groups = [str(g) for g in payload_groups if str(g)]
- return AuthentikPrincipal(
+ return KeycloakPrincipal(
sub=principal.sub,
email=email,
name=name,
@@ -105,7 +132,7 @@ class AuthentikTokenVerifier:
groups=groups,
)
- def verify_access_token(self, token: str) -> AuthentikPrincipal:
+ def verify_access_token(self, token: str) -> KeycloakPrincipal:
try:
header = jwt.get_unverified_header(token)
algorithm = str(header.get("alg", "")).upper()
@@ -120,7 +147,7 @@ class AuthentikTokenVerifier:
if not self.client_secret:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
- detail="missing_authentik_client_secret",
+ detail="missing_idp_client_secret",
)
key = self.client_secret
allowed_algorithms = ["HS256", "HS384", "HS512"]
@@ -138,13 +165,17 @@ class AuthentikTokenVerifier:
options=options,
)
except Exception as exc:
- raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="invalid_bearer_token") from exc
+ claims = self._introspect_token(token)
+ if claims:
+ logger.warning("jwt verify failed, used introspection fallback: %s", exc)
+ else:
+ raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="invalid_bearer_token") from exc
sub = claims.get("sub")
if not sub:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="token_missing_sub")
- principal = AuthentikPrincipal(
+ principal = KeycloakPrincipal(
sub=sub,
email=claims.get("email"),
name=claims.get("name"),
@@ -155,12 +186,13 @@ class AuthentikTokenVerifier:
@lru_cache
-def _get_verifier() -> AuthentikTokenVerifier:
+def _get_verifier() -> KeycloakTokenVerifier:
settings = get_settings()
- return AuthentikTokenVerifier(
+ return KeycloakTokenVerifier(
issuer=settings.idp_issuer,
jwks_url=settings.idp_jwks_url,
audience=settings.idp_audience,
+ client_id=settings.idp_client_id,
client_secret=settings.idp_client_secret,
base_url=settings.idp_base_url,
userinfo_endpoint=settings.idp_userinfo_endpoint,
@@ -170,7 +202,7 @@ def _get_verifier() -> AuthentikTokenVerifier:
def require_authenticated_principal(
credentials: HTTPAuthorizationCredentials | None = Depends(bearer_scheme),
-) -> AuthentikPrincipal:
+) -> KeycloakPrincipal:
if credentials is None or credentials.scheme.lower() != "bearer":
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="missing_bearer_token")
diff --git a/app/services/authentik_admin_service.py b/app/services/authentik_admin_service.py
deleted file mode 100644
index bafe3cf..0000000
--- a/app/services/authentik_admin_service.py
+++ /dev/null
@@ -1,369 +0,0 @@
-from __future__ import annotations
-
-from dataclasses import dataclass
-import secrets
-import string
-
-import httpx
-from fastapi import HTTPException, status
-
-from app.core.config import Settings
-
-
-@dataclass
-class AuthentikSyncResult:
- user_id: str
- action: str
- user_sub: str | None = None
-
-
-@dataclass
-class AuthentikPasswordResetResult:
- user_id: str
- temporary_password: str
-
-
-@dataclass
-class AuthentikDeleteResult:
- action: str
- user_id: str | None = None
-
-
-class AuthentikAdminService:
- """
- Backward-compatible service name.
- Supports Keycloak (preferred, when KEYCLOAK_* configured) and Authentik.
- """
-
- def __init__(self, settings: Settings) -> None:
- self.settings = settings
- self.is_keycloak = settings.use_keycloak
- self.verify_tls = settings.idp_verify_tls
-
- if self.is_keycloak:
- self.base_url = settings.keycloak_base_url.rstrip("/")
- self.realm = settings.keycloak_realm
- self.admin_realm = settings.keycloak_admin_realm or settings.keycloak_realm
- self.admin_client_id = settings.keycloak_admin_client_id
- self.admin_client_secret = settings.keycloak_admin_client_secret
- if not self.base_url or not self.realm or not self.admin_client_id or not self.admin_client_secret:
- raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="authentik_admin_not_configured")
- else:
- self.base_url = settings.authentik_base_url.rstrip("/")
- self.admin_token = settings.authentik_admin_token
- if not self.base_url or not self.admin_token:
- raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="authentik_admin_not_configured")
-
- @staticmethod
- def _safe_username(sub: str | None, email: str) -> str:
- if email and "@" in email:
- return email.split("@", 1)[0]
- if sub:
- return sub.replace("|", "_")[:150]
- return "member-user"
-
- @staticmethod
- def _generate_temporary_password(length: int = 14) -> str:
- alphabet = string.ascii_letters + string.digits + "!@#$%^&*"
- return "".join(secrets.choice(alphabet) for _ in range(length))
-
- @staticmethod
- def _extract_first_result(data: dict) -> dict | None:
- results = data.get("results") if isinstance(data, dict) else None
- return results[0] if isinstance(results, list) and results else None
-
- def _get_keycloak_admin_token(self) -> str:
- token_endpoint = f"{self.base_url}/realms/{self.admin_realm}/protocol/openid-connect/token"
- try:
- resp = httpx.post(
- token_endpoint,
- data={
- "grant_type": "client_credentials",
- "client_id": self.admin_client_id,
- "client_secret": self.admin_client_secret,
- },
- timeout=10,
- verify=self.verify_tls,
- headers={"Content-Type": "application/x-www-form-urlencoded"},
- )
- except Exception as exc:
- raise HTTPException(status_code=502, detail="authentik_lookup_failed") from exc
-
- if resp.status_code >= 400:
- raise HTTPException(status_code=502, detail="authentik_lookup_failed")
- token = resp.json().get("access_token")
- if not token:
- raise HTTPException(status_code=502, detail="authentik_lookup_failed")
- return str(token)
-
- def _client(self) -> httpx.Client:
- if self.is_keycloak:
- bearer_token = self._get_keycloak_admin_token()
- else:
- bearer_token = self.admin_token
- return httpx.Client(
- base_url=self.base_url,
- headers={
- "Authorization": f"Bearer {bearer_token}",
- "Accept": "application/json",
- "Content-Type": "application/json",
- },
- timeout=10,
- verify=self.verify_tls,
- )
-
- # -------- Authentik lookups --------
- def _ak_lookup_user_by_id(self, client: httpx.Client, user_id: str) -> dict | None:
- resp = client.get(f"/api/v3/core/users/{user_id}/")
- if resp.status_code == 404:
- return None
- if resp.status_code >= 400:
- raise HTTPException(status_code=502, detail="authentik_lookup_failed")
- return resp.json()
-
- def _ak_lookup_user_by_email_or_username(
- self, client: httpx.Client, *, email: str | None, username: str | None
- ) -> dict | None:
- if email:
- resp = client.get("/api/v3/core/users/", params={"email": email})
- if resp.status_code >= 400:
- raise HTTPException(status_code=502, detail="authentik_lookup_failed")
- existing = self._extract_first_result(resp.json())
- if existing:
- return existing
- if username:
- resp = client.get("/api/v3/core/users/", params={"username": username})
- if resp.status_code >= 400:
- raise HTTPException(status_code=502, detail="authentik_lookup_failed")
- existing = self._extract_first_result(resp.json())
- if existing:
- return existing
- return None
-
- # -------- Keycloak lookups --------
- def _kc_lookup_user_by_id(self, client: httpx.Client, user_id: str) -> dict | None:
- resp = client.get(f"/admin/realms/{self.realm}/users/{user_id}")
- if resp.status_code == 404:
- return None
- if resp.status_code >= 400:
- raise HTTPException(status_code=502, detail="authentik_lookup_failed")
- return resp.json()
-
- def _kc_lookup_user_by_email_or_username(
- self, client: httpx.Client, *, email: str | None, username: str | None
- ) -> dict | None:
- if email:
- resp = client.get(f"/admin/realms/{self.realm}/users", params={"email": email, "exact": "true"})
- if resp.status_code >= 400:
- raise HTTPException(status_code=502, detail="authentik_lookup_failed")
- matches = resp.json() if isinstance(resp.json(), list) else []
- if matches:
- return matches[0]
- if username:
- resp = client.get(f"/admin/realms/{self.realm}/users", params={"username": username, "exact": "true"})
- if resp.status_code >= 400:
- raise HTTPException(status_code=502, detail="authentik_lookup_failed")
- matches = resp.json() if isinstance(resp.json(), list) else []
- if matches:
- return matches[0]
- return None
-
- def ensure_user(
- self,
- *,
- sub: str | None,
- email: str,
- username: str | None,
- display_name: str | None,
- is_active: bool = True,
- idp_user_id: str | None = None,
- ) -> AuthentikSyncResult:
- resolved_username = username or self._safe_username(sub=sub, email=email)
-
- with self._client() as client:
- if self.is_keycloak:
- return self._ensure_user_keycloak(
- client,
- sub=sub,
- email=email,
- resolved_username=resolved_username,
- display_name=display_name,
- is_active=is_active,
- idp_user_id=idp_user_id,
- )
- return self._ensure_user_authentik(
- client,
- sub=sub,
- email=email,
- resolved_username=resolved_username,
- display_name=display_name,
- is_active=is_active,
- idp_user_id=idp_user_id,
- )
-
- def _ensure_user_authentik(
- self,
- client: httpx.Client,
- *,
- sub: str | None,
- email: str,
- resolved_username: str,
- display_name: str | None,
- is_active: bool,
- idp_user_id: str | None,
- ) -> AuthentikSyncResult:
- payload = {
- "username": resolved_username,
- "name": display_name or email,
- "email": email,
- "is_active": is_active,
- }
- existing = None
- if idp_user_id:
- existing = self._ak_lookup_user_by_id(client, idp_user_id)
- if existing is None:
- existing = self._ak_lookup_user_by_email_or_username(client, email=email, username=resolved_username)
-
- if existing and existing.get("pk") is not None:
- user_pk = str(existing["pk"])
- patch_resp = client.patch(f"/api/v3/core/users/{user_pk}/", json=payload)
- if patch_resp.status_code >= 400:
- raise HTTPException(status_code=502, detail="authentik_update_failed")
- return AuthentikSyncResult(user_id=user_pk, action="updated", user_sub=existing.get("uid"))
-
- create_resp = client.post("/api/v3/core/users/", json=payload)
- if create_resp.status_code >= 400:
- raise HTTPException(status_code=502, detail="authentik_create_failed")
- created = create_resp.json()
- return AuthentikSyncResult(user_id=str(created["pk"]), action="created", user_sub=created.get("uid"))
-
- def _ensure_user_keycloak(
- self,
- client: httpx.Client,
- *,
- sub: str | None,
- email: str,
- resolved_username: str,
- display_name: str | None,
- is_active: bool,
- idp_user_id: str | None,
- ) -> AuthentikSyncResult:
- first_name = display_name or resolved_username
- payload = {
- "username": resolved_username,
- "email": email,
- "enabled": is_active,
- "emailVerified": True,
- "firstName": first_name,
- "attributes": {"user_sub": [sub]} if sub else {},
- }
-
- existing = None
- if idp_user_id:
- existing = self._kc_lookup_user_by_id(client, idp_user_id)
- if existing is None:
- existing = self._kc_lookup_user_by_email_or_username(client, email=email, username=resolved_username)
-
- if existing and existing.get("id"):
- user_id = str(existing["id"])
- put_resp = client.put(f"/admin/realms/{self.realm}/users/{user_id}", json=payload)
- if put_resp.status_code >= 400:
- raise HTTPException(status_code=502, detail="authentik_update_failed")
- return AuthentikSyncResult(user_id=user_id, action="updated", user_sub=user_id)
-
- create_resp = client.post(f"/admin/realms/{self.realm}/users", json=payload)
- if create_resp.status_code >= 400:
- raise HTTPException(status_code=502, detail="authentik_create_failed")
-
- user_id: str | None = None
- location = create_resp.headers.get("Location", "")
- if location and "/" in location:
- user_id = location.rstrip("/").split("/")[-1]
- if not user_id:
- found = self._kc_lookup_user_by_email_or_username(client, email=email, username=resolved_username)
- user_id = str(found["id"]) if found and found.get("id") else None
- if not user_id:
- raise HTTPException(status_code=502, detail="authentik_create_failed")
- return AuthentikSyncResult(user_id=user_id, action="created", user_sub=user_id)
-
- def reset_password(
- self,
- *,
- idp_user_id: str | None,
- email: str | None,
- username: str | None,
- ) -> AuthentikPasswordResetResult:
- with self._client() as client:
- if self.is_keycloak:
- existing = None
- if idp_user_id:
- existing = self._kc_lookup_user_by_id(client, idp_user_id)
- if existing is None:
- existing = self._kc_lookup_user_by_email_or_username(client, email=email, username=username)
- if not existing or not existing.get("id"):
- raise HTTPException(status_code=404, detail="authentik_user_not_found")
- user_id = str(existing["id"])
- temp_password = self._generate_temporary_password()
- resp = client.put(
- f"/admin/realms/{self.realm}/users/{user_id}/reset-password",
- json={"type": "password", "value": temp_password, "temporary": True},
- )
- if resp.status_code >= 400:
- raise HTTPException(status_code=502, detail="authentik_set_password_failed")
- return AuthentikPasswordResetResult(user_id=user_id, temporary_password=temp_password)
-
- existing = None
- if idp_user_id:
- existing = self._ak_lookup_user_by_id(client, idp_user_id)
- if existing is None:
- existing = self._ak_lookup_user_by_email_or_username(client, email=email, username=username)
- if not existing or existing.get("pk") is None:
- raise HTTPException(status_code=404, detail="authentik_user_not_found")
- user_pk = str(existing["pk"])
- temp_password = self._generate_temporary_password()
- set_pwd_resp = client.post(f"/api/v3/core/users/{user_pk}/set_password/", json={"password": temp_password})
- if set_pwd_resp.status_code >= 400:
- raise HTTPException(status_code=502, detail="authentik_set_password_failed")
- return AuthentikPasswordResetResult(user_id=user_pk, temporary_password=temp_password)
-
- def delete_user(
- self,
- *,
- idp_user_id: str | None,
- email: str | None,
- username: str | None,
- ) -> AuthentikDeleteResult:
- with self._client() as client:
- if self.is_keycloak:
- existing = None
- if idp_user_id:
- existing = self._kc_lookup_user_by_id(client, idp_user_id)
- if existing is None:
- existing = self._kc_lookup_user_by_email_or_username(client, email=email, username=username)
- if not existing or not existing.get("id"):
- return AuthentikDeleteResult(action="not_found")
- user_id = str(existing["id"])
- resp = client.delete(f"/admin/realms/{self.realm}/users/{user_id}")
- if resp.status_code in {204, 404}:
- return AuthentikDeleteResult(action="deleted" if resp.status_code == 204 else "not_found", user_id=user_id)
- if resp.status_code >= 400:
- raise HTTPException(status_code=502, detail="authentik_delete_failed")
- return AuthentikDeleteResult(action="deleted", user_id=user_id)
-
- existing = None
- if idp_user_id:
- existing = self._ak_lookup_user_by_id(client, idp_user_id)
- if existing is None:
- existing = self._ak_lookup_user_by_email_or_username(client, email=email, username=username)
- if not existing or existing.get("pk") is None:
- return AuthentikDeleteResult(action="not_found")
- user_pk = str(existing["pk"])
- delete_resp = client.delete(f"/api/v3/core/users/{user_pk}/")
- if delete_resp.status_code in {204, 404}:
- return AuthentikDeleteResult(
- action="deleted" if delete_resp.status_code == 204 else "not_found",
- user_id=user_pk,
- )
- if delete_resp.status_code >= 400:
- raise HTTPException(status_code=502, detail="authentik_delete_failed")
- return AuthentikDeleteResult(action="deleted", user_id=user_pk)
diff --git a/app/services/idp_admin_service.py b/app/services/idp_admin_service.py
new file mode 100644
index 0000000..4e0e337
--- /dev/null
+++ b/app/services/idp_admin_service.py
@@ -0,0 +1,209 @@
+from __future__ import annotations
+
+from dataclasses import dataclass
+import secrets
+import string
+
+import httpx
+from fastapi import HTTPException, status
+
+from app.core.config import Settings
+
+
+@dataclass
+class KeycloakSyncResult:
+ user_id: str
+ action: str
+ user_sub: str | None = None
+
+
+@dataclass
+class KeycloakPasswordResetResult:
+ user_id: str
+ temporary_password: str
+
+
+@dataclass
+class KeycloakDeleteResult:
+ action: str
+ user_id: str | None = None
+
+
+class KeycloakAdminService:
+ def __init__(self, settings: Settings) -> None:
+ self.base_url = settings.keycloak_base_url.rstrip("/")
+ self.realm = settings.keycloak_realm
+ self.admin_realm = settings.keycloak_admin_realm or settings.keycloak_realm
+ self.admin_client_id = settings.keycloak_admin_client_id
+ self.admin_client_secret = settings.keycloak_admin_client_secret
+ self.verify_tls = settings.keycloak_verify_tls
+
+ if not self.base_url or not self.realm or not self.admin_client_id or not self.admin_client_secret:
+ raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="idp_admin_not_configured")
+
+ @staticmethod
+ def _safe_username(sub: str | None, email: str) -> str:
+ if email and "@" in email:
+ return email.split("@", 1)[0]
+ if sub:
+ return sub.replace("|", "_")[:150]
+ return "member-user"
+
+ @staticmethod
+ def _generate_temporary_password(length: int = 14) -> str:
+ alphabet = string.ascii_letters + string.digits + "!@#$%^&*"
+ return "".join(secrets.choice(alphabet) for _ in range(length))
+
+ def _get_admin_token(self) -> str:
+ token_endpoint = f"{self.base_url}/realms/{self.admin_realm}/protocol/openid-connect/token"
+ try:
+ resp = httpx.post(
+ token_endpoint,
+ data={
+ "grant_type": "client_credentials",
+ "client_id": self.admin_client_id,
+ "client_secret": self.admin_client_secret,
+ },
+ timeout=10,
+ verify=self.verify_tls,
+ headers={"Content-Type": "application/x-www-form-urlencoded"},
+ )
+ except Exception as exc:
+ raise HTTPException(status_code=502, detail="idp_lookup_failed") from exc
+ if resp.status_code >= 400:
+ raise HTTPException(status_code=502, detail="idp_lookup_failed")
+ token = resp.json().get("access_token")
+ if not token:
+ raise HTTPException(status_code=502, detail="idp_lookup_failed")
+ return str(token)
+
+ def _client(self) -> httpx.Client:
+ return httpx.Client(
+ base_url=self.base_url,
+ headers={
+ "Authorization": f"Bearer {self._get_admin_token()}",
+ "Accept": "application/json",
+ "Content-Type": "application/json",
+ },
+ timeout=10,
+ verify=self.verify_tls,
+ )
+
+ def _lookup_user_by_id(self, client: httpx.Client, user_id: str) -> dict | None:
+ resp = client.get(f"/admin/realms/{self.realm}/users/{user_id}")
+ if resp.status_code == 404:
+ return None
+ if resp.status_code >= 400:
+ raise HTTPException(status_code=502, detail="idp_lookup_failed")
+ return resp.json()
+
+ def _lookup_user_by_email_or_username(
+ self, client: httpx.Client, *, email: str | None, username: str | None
+ ) -> dict | None:
+ if email:
+ resp = client.get(f"/admin/realms/{self.realm}/users", params={"email": email, "exact": "true"})
+ if resp.status_code >= 400:
+ raise HTTPException(status_code=502, detail="idp_lookup_failed")
+ matches = resp.json() if isinstance(resp.json(), list) else []
+ if matches:
+ return matches[0]
+ if username:
+ resp = client.get(f"/admin/realms/{self.realm}/users", params={"username": username, "exact": "true"})
+ if resp.status_code >= 400:
+ raise HTTPException(status_code=502, detail="idp_lookup_failed")
+ matches = resp.json() if isinstance(resp.json(), list) else []
+ if matches:
+ return matches[0]
+ return None
+
+ def ensure_user(
+ self,
+ *,
+ sub: str | None,
+ email: str,
+ username: str | None,
+ display_name: str | None,
+ is_active: bool = True,
+ idp_user_id: str | None = None,
+ ) -> KeycloakSyncResult:
+ resolved_username = username or self._safe_username(sub=sub, email=email)
+ first_name = display_name or resolved_username
+ payload = {
+ "username": resolved_username,
+ "email": email,
+ "enabled": is_active,
+ "emailVerified": True,
+ "firstName": first_name,
+ "attributes": {"user_sub": [sub]} if sub else {},
+ }
+
+ with self._client() as client:
+ existing = self._lookup_user_by_id(client, idp_user_id) if idp_user_id else None
+ if existing is None:
+ existing = self._lookup_user_by_email_or_username(client, email=email, username=resolved_username)
+
+ if existing and existing.get("id"):
+ user_id = str(existing["id"])
+ put_resp = client.put(f"/admin/realms/{self.realm}/users/{user_id}", json=payload)
+ if put_resp.status_code >= 400:
+ raise HTTPException(status_code=502, detail="idp_update_failed")
+ return KeycloakSyncResult(user_id=user_id, action="updated", user_sub=user_id)
+
+ create_resp = client.post(f"/admin/realms/{self.realm}/users", json=payload)
+ if create_resp.status_code >= 400:
+ raise HTTPException(status_code=502, detail="idp_create_failed")
+
+ location = create_resp.headers.get("Location", "")
+ user_id = location.rstrip("/").split("/")[-1] if location and "/" in location else ""
+ if not user_id:
+ found = self._lookup_user_by_email_or_username(client, email=email, username=resolved_username)
+ user_id = str(found["id"]) if found and found.get("id") else ""
+ if not user_id:
+ raise HTTPException(status_code=502, detail="idp_create_failed")
+ return KeycloakSyncResult(user_id=user_id, action="created", user_sub=user_id)
+
+ def reset_password(
+ self,
+ *,
+ idp_user_id: str | None,
+ email: str | None,
+ username: str | None,
+ ) -> KeycloakPasswordResetResult:
+ with self._client() as client:
+ existing = self._lookup_user_by_id(client, idp_user_id) if idp_user_id else None
+ if existing is None:
+ existing = self._lookup_user_by_email_or_username(client, email=email, username=username)
+ if not existing or not existing.get("id"):
+ raise HTTPException(status_code=404, detail="idp_user_not_found")
+
+ user_id = str(existing["id"])
+ temp_password = self._generate_temporary_password()
+ resp = client.put(
+ f"/admin/realms/{self.realm}/users/{user_id}/reset-password",
+ json={"type": "password", "value": temp_password, "temporary": True},
+ )
+ if resp.status_code >= 400:
+ raise HTTPException(status_code=502, detail="idp_set_password_failed")
+ return KeycloakPasswordResetResult(user_id=user_id, temporary_password=temp_password)
+
+ def delete_user(
+ self,
+ *,
+ idp_user_id: str | None,
+ email: str | None,
+ username: str | None,
+ ) -> KeycloakDeleteResult:
+ with self._client() as client:
+ existing = self._lookup_user_by_id(client, idp_user_id) if idp_user_id else None
+ if existing is None:
+ existing = self._lookup_user_by_email_or_username(client, email=email, username=username)
+ if not existing or not existing.get("id"):
+ return KeycloakDeleteResult(action="not_found")
+
+ user_id = str(existing["id"])
+ resp = client.delete(f"/admin/realms/{self.realm}/users/{user_id}")
+ if resp.status_code in {204, 404}:
+ return KeycloakDeleteResult(action="deleted" if resp.status_code == 204 else "not_found", user_id=user_id)
+ if resp.status_code >= 400:
+ raise HTTPException(status_code=502, detail="idp_delete_failed")
+ return KeycloakDeleteResult(action="deleted", user_id=user_id)
diff --git a/scripts/migrate_add_authentik_user_id.sql b/scripts/migrate_add_idp_user_id.sql
similarity index 100%
rename from scripts/migrate_add_authentik_user_id.sql
rename to scripts/migrate_add_idp_user_id.sql
diff --git a/scripts/migrate_rename_identity_columns.sql b/scripts/migrate_rename_identity_columns.sql
index 7a78ed5..f44f7a2 100644
--- a/scripts/migrate_rename_identity_columns.sql
+++ b/scripts/migrate_rename_identity_columns.sql
@@ -4,38 +4,38 @@ DO $$
BEGIN
IF EXISTS (
SELECT 1 FROM information_schema.columns
- WHERE table_name = 'users' AND column_name = 'authentik_sub'
+ WHERE table_name = 'users' AND column_name = 'idp_sub'
) AND NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'users' AND column_name = 'user_sub'
) THEN
- ALTER TABLE users RENAME COLUMN authentik_sub TO user_sub;
+ ALTER TABLE users RENAME COLUMN idp_sub TO user_sub;
END IF;
IF EXISTS (
SELECT 1 FROM information_schema.columns
- WHERE table_name = 'users' AND column_name = 'authentik_user_id'
+ WHERE table_name = 'users' AND column_name = 'idp_user_id'
) AND NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'users' AND column_name = 'idp_user_id'
) THEN
- ALTER TABLE users RENAME COLUMN authentik_user_id TO idp_user_id;
+ ALTER TABLE users RENAME COLUMN idp_user_id TO idp_user_id;
END IF;
IF EXISTS (
SELECT 1 FROM information_schema.columns
- WHERE table_name = 'permission_group_members' AND column_name = 'authentik_sub'
+ WHERE table_name = 'permission_group_members' AND column_name = 'idp_sub'
) AND NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'permission_group_members' AND column_name = 'user_sub'
) THEN
- ALTER TABLE permission_group_members RENAME COLUMN authentik_sub TO user_sub;
+ ALTER TABLE permission_group_members RENAME COLUMN idp_sub TO user_sub;
END IF;
END
$$;
-ALTER INDEX IF EXISTS idx_users_authentik_sub RENAME TO idx_users_user_sub;
-ALTER INDEX IF EXISTS idx_pgm_authentik_sub RENAME TO idx_pgm_user_sub;
+ALTER INDEX IF EXISTS idx_users_idp_sub RENAME TO idx_users_user_sub;
+ALTER INDEX IF EXISTS idx_pgm_idp_sub RENAME TO idx_pgm_user_sub;
CREATE INDEX IF NOT EXISTS idx_users_user_sub ON users(user_sub);
CREATE INDEX IF NOT EXISTS idx_pgm_user_sub ON permission_group_members(user_sub);
diff --git a/tests/test_authentik_jwt.py b/tests/test_idp_jwt.py
similarity index 56%
rename from tests/test_authentik_jwt.py
rename to tests/test_idp_jwt.py
index 88e68f7..a1061f3 100644
--- a/tests/test_authentik_jwt.py
+++ b/tests/test_idp_jwt.py
@@ -1,12 +1,12 @@
from fastapi.testclient import TestClient
from app.main import app
-from app.security.authentik_jwt import AuthentikTokenVerifier
+from app.security.idp_jwt import KeycloakTokenVerifier
def test_infer_jwks_url() -> None:
- assert AuthentikTokenVerifier._infer_jwks_url("https://auth.ose.tw/application/o/member/") == (
- "https://auth.ose.tw/application/o/member/jwks/"
+ assert KeycloakTokenVerifier._infer_jwks_url("https://auth.ose.tw/application/o/member/") == (
+ "https://auth.ose.tw/application/o/member/protocol/openid-connect/certs"
)
diff --git a/tests/test_internal_authentik_sync.py b/tests/test_internal_idp_sync.py
similarity index 70%
rename from tests/test_internal_authentik_sync.py
rename to tests/test_internal_idp_sync.py
index a48d522..da1feee 100644
--- a/tests/test_internal_authentik_sync.py
+++ b/tests/test_internal_idp_sync.py
@@ -4,20 +4,20 @@ from app.main import app
from app.security.api_client_auth import require_api_client
-def test_internal_authentik_ensure_requires_config() -> None:
+def test_internal_idp_ensure_requires_config() -> None:
app.dependency_overrides[require_api_client] = lambda: None
client = TestClient(app)
try:
resp = client.post(
- "/internal/authentik/users/ensure",
+ "/internal/idp/users/ensure",
json={
- "sub": "authentik-sub-1",
+ "sub": "idp-sub-1",
"email": "user@example.com",
"display_name": "User Example",
"is_active": True,
},
)
assert resp.status_code == 503
- assert resp.json()["detail"] == "authentik_admin_not_configured"
+ assert resp.json()["detail"] == "idp_admin_not_configured"
finally:
app.dependency_overrides.pop(require_api_client, None)